Include preceding assistant context in Guardian sender reviews (#49951)

## Why

Short sender replies such as `👍` need preceding assistant context to be understood during a delegation review. User messages alone omit that context.

## What changed

- Capture preceding assistant context alongside up to three recent sender user messages at delivery, for Guardian only. Label assistant messages as untrusted context rather than authorization or verified questions.
- Share the existing 900-byte budget per exchange, prioritize user evidence, and report unavailable assistant context.
- Recognize `cloud_threads.send_message` deliveries and accept both compact and indented delegation envelopes.

## Testing

Expand integration coverage for app, TUI, and cloud-thread deliveries, contextual replies, and budget-driven omissions. Assert that sender user and assistant context stays out of the delegated agent's prompt.

GitOrigin-RevId: 4eca369cbb30241635273f729dec81b78ea57faf
This commit is contained in:
jif
2026-10-01 10:47:18 +00:00
committed by copyberry
parent dd90f160ed
commit f70810bcd2
4 changed files with 141 additions and 57 deletions
@@ -1,9 +1,11 @@
//! Captures genuine sender instructions for host-delivered task messages.
//! Captures genuine sender instructions and assistant context for host-delivered task messages.
//! Only turn-input admission calls this, before queueing; ordinary tool results and
//! quoted delegation text cannot establish sender provenance. Lookup stays in this host.
use crate::context::ContextualUserFragment;
use crate::context::GuardianSenderExchange;
use crate::context::GuardianSenderMessages;
use codex_guardian_context::GuardianRootMessage;
use codex_history::RetainedContextEntry;
use codex_history::RetainedContextOrder;
use codex_history::SenderUserMessages;
@@ -30,17 +32,25 @@ impl LocalAgentRuntime {
else {
return None;
};
if !matches!(namespace.as_str(), "codex_app" | "codex_tui")
|| name != "send_message_to_thread"
{
if !matches!(
(namespace.as_str(), name.as_str()),
("codex_app" | "codex_tui", "send_message_to_thread")
| ("cloud_threads", "send_message")
) {
return None;
}
// Recognized deliveries always get their own snapshot, even without usable provenance.
let source_thread_id = output.body.to_text().and_then(|text| {
let (source, input) = text
.strip_prefix("<codex_delegation>\n <source_thread_id>")?
.split_once("</source_thread_id>\n <input>")?;
input.strip_suffix("</input>\n</codex_delegation>")?;
.strip_prefix("<codex_delegation>")?
.strip_suffix("</codex_delegation>")?
.trim()
.strip_prefix("<source_thread_id>")?
.split_once("</source_thread_id>")?;
input
.trim()
.strip_prefix("<input>")?
.strip_suffix("</input>")?;
ThreadId::from_string(source)
.ok()
.filter(|source| *source != receiver_thread_id)
@@ -56,19 +66,36 @@ impl LocalAgentRuntime {
{
let history = sender.conversation_history_snapshot().await;
if let Some(context) = history.retained_context() {
fragment.messages = context
.ordered_entries()
.filter_map(|(order, entry)| match (order, entry) {
let mut exchanges = Vec::new();
let mut assistant = None;
for (order, entry) in context.ordered_entries() {
match (order, entry) {
(
RetainedContextOrder::Local(_),
RetainedContextEntry::UserMessage(message),
) => Some(message.complete.then(|| message.text.clone())),
) => exchanges.push((message, assistant.take())),
(
RetainedContextOrder::Local(_),
RetainedContextEntry::AssistantMessage(message),
) => assistant = Some(message),
(RetainedContextOrder::Inherited(_), _)
| (_, RetainedContextEntry::AssistantMessage(_))
| (_, RetainedContextEntry::VerifiedAnswer(_)) => None,
})
| (_, RetainedContextEntry::VerifiedAnswer(_)) => {}
}
}
fragment.messages = exchanges
.into_iter()
.rev()
.take(/*n*/ 3)
.map(|(user, assistant)| GuardianSenderExchange {
user: user.complete.then(|| user.text.clone()),
assistant: assistant.map(|message| {
if message.complete {
GuardianRootMessage::Assistant(message.text.clone())
} else {
GuardianRootMessage::IncompleteAssistantContext
}
}),
})
.collect();
fragment.messages.reverse();
}