mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
## Why Guardian review needs the original function-call metadata even when the call is no longer in live conversation history. Looking it up in history can lose the evidence needed to review encrypted multi-agent messages. ## What changed Capture native-review policy in the tool registry and retain the original call through dispatch for multi-agent tools that require review. Use that retained call for Guardian review, applying hook-rewritten arguments while preserving encrypted-argument metadata and clearing stale replay content. Limit `ToolCall` debug output to the tool name and call ID. ## Testing Add coverage for selective call retention across review settings and namespaces. Extend Guardian tests to cover calls absent from live history and hook rewrites that preserve call identity and encrypted-argument metadata across incremental reviews. GitOrigin-RevId: 0e49833741c09c29228440bddd2e12695ae99583
601 lines
20 KiB
Rust
601 lines
20 KiB
Rust
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
|
|
use codex_extension_api::ExtensionData;
|
|
use codex_history::ResponseItemEnvelope;
|
|
use codex_protocol::ResponseItemId;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_utils_stream_parser::strip_citations;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::function_tool::FunctionCallError;
|
|
use crate::parse_turn_item;
|
|
use crate::session::session::Session;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::turn_context::TurnContext;
|
|
use crate::tools::call_trace;
|
|
use crate::tools::parallel::ToolCallRuntime;
|
|
use codex_memories_read::citations::parse_memory_citation;
|
|
use codex_memories_read::citations::thread_ids_from_memory_citation;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::Result;
|
|
use codex_protocol::memory_citation::MemoryCitation;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use codex_protocol::models::MessagePhase;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_rollout::state_db;
|
|
use codex_utils_stream_parser::strip_proposed_plan_blocks;
|
|
use futures::Future;
|
|
use tracing::debug;
|
|
use tracing::instrument;
|
|
use tracing::warn;
|
|
|
|
fn strip_hidden_assistant_markup(text: &str, plan_mode: bool) -> String {
|
|
let (without_citations, _) = strip_citations(text);
|
|
if plan_mode {
|
|
strip_proposed_plan_blocks(&without_citations)
|
|
} else {
|
|
without_citations
|
|
}
|
|
}
|
|
|
|
fn strip_hidden_assistant_markup_and_parse_memory_citation(
|
|
text: &str,
|
|
plan_mode: bool,
|
|
) -> (
|
|
String,
|
|
Option<codex_protocol::memory_citation::MemoryCitation>,
|
|
) {
|
|
let (without_citations, citations) = strip_citations(text);
|
|
let visible_text = if plan_mode {
|
|
strip_proposed_plan_blocks(&without_citations)
|
|
} else {
|
|
without_citations
|
|
};
|
|
(visible_text, parse_memory_citation(citations))
|
|
}
|
|
|
|
pub(crate) fn raw_assistant_output_text_from_item(item: &ResponseItem) -> Option<String> {
|
|
if let ResponseItem::Message { role, content, .. } = item
|
|
&& role == "assistant"
|
|
{
|
|
let combined = content
|
|
.iter()
|
|
.filter_map(|ci| match ci {
|
|
codex_protocol::models::ContentItem::OutputText { text, .. } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<String>();
|
|
return Some(combined);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Persist a completed model response item and record any cited memory usage.
|
|
pub(crate) async fn record_completed_response_item(
|
|
sess: &Session,
|
|
step_context: &StepContext,
|
|
item: &ResponseItem,
|
|
) {
|
|
record_completed_response_item_with_finalized_facts(
|
|
sess,
|
|
step_context,
|
|
item,
|
|
/*finalized_facts*/ None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn record_completed_response_item_with_finalized_facts(
|
|
sess: &Session,
|
|
step_context: &StepContext,
|
|
item: &ResponseItem,
|
|
finalized_facts: Option<&FinalizedTurnItemFacts>,
|
|
) {
|
|
let turn_context = &step_context.turn;
|
|
sess.record_conversation_items(
|
|
turn_context,
|
|
&step_context.settings.model_info,
|
|
std::slice::from_ref(item),
|
|
)
|
|
.await;
|
|
if turn_context.config.otel.agent_response_logging_enabled() {
|
|
turn_context
|
|
.extension_data
|
|
.get_or_init(codex_otel::AgentResponseLogger::default)
|
|
.record(
|
|
&step_context.session_telemetry,
|
|
item,
|
|
codex_otel::AgentResponseContext {
|
|
turn_id: &turn_context.sub_id,
|
|
session_source: &turn_context.session_source,
|
|
parent_turn_id: turn_context.turn_metadata_state.parent_turn_id(),
|
|
root_turn_id: turn_context.turn_metadata_state.root_turn_id(),
|
|
initiating_agent_path: turn_context.turn_metadata_state.initiating_agent_path(),
|
|
},
|
|
);
|
|
}
|
|
let defers_mailbox_delivery = finalized_facts.map_or_else(
|
|
|| {
|
|
completed_item_defers_mailbox_delivery_to_next_turn(
|
|
item,
|
|
turn_context.mode() == ModeKind::Plan,
|
|
)
|
|
},
|
|
|facts| facts.defers_mailbox_delivery_to_next_turn,
|
|
);
|
|
if defers_mailbox_delivery {
|
|
sess.input_queue
|
|
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &turn_context.sub_id)
|
|
.await;
|
|
}
|
|
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
|
|
let memory_usage_db = match sess.services.state_db.as_ref() {
|
|
Some(db) => db
|
|
.memories_for_version(turn_context.config.memories.version)
|
|
.await
|
|
.ok(),
|
|
None => None,
|
|
};
|
|
let has_memory_citation = if let Some(memory_citation) =
|
|
finalized_facts.and_then(|facts| facts.memory_citation.as_ref())
|
|
{
|
|
record_stage1_output_usage_for_memory_citation(memory_usage_db.as_ref(), memory_citation)
|
|
.await
|
|
} else {
|
|
record_stage1_output_usage_and_detect_memory_citation(memory_usage_db.as_ref(), item).await
|
|
};
|
|
if has_memory_citation {
|
|
sess.record_memory_citation_for_turn(&turn_context.sub_id)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
fn response_item_may_include_external_context(item: &ResponseItem) -> bool {
|
|
matches!(
|
|
item,
|
|
ResponseItem::ToolSearchCall { .. }
|
|
| ResponseItem::ToolSearchOutput { .. }
|
|
| ResponseItem::WebSearchCall { .. }
|
|
| ResponseItem::FunctionCallOutput { call_id: None, .. }
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
item: &ResponseItem,
|
|
) {
|
|
if !turn_context.config.memories.disable_on_external_context
|
|
|| !response_item_may_include_external_context(item)
|
|
{
|
|
return;
|
|
}
|
|
state_db::mark_thread_memory_mode_polluted(
|
|
sess.services.state_db.as_deref(),
|
|
sess.thread_id,
|
|
"record_completed_response_item",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_stage1_output_usage_and_detect_memory_citation(
|
|
state_db_ctx: Option<&codex_state::MemoryStore>,
|
|
item: &ResponseItem,
|
|
) -> bool {
|
|
let Some(raw_text) = raw_assistant_output_text_from_item(item) else {
|
|
return false;
|
|
};
|
|
|
|
let (_, citations) = strip_citations(&raw_text);
|
|
let Some(memory_citation) = parse_memory_citation(citations) else {
|
|
return false;
|
|
};
|
|
record_stage1_output_usage_for_memory_citation(state_db_ctx, &memory_citation).await
|
|
}
|
|
|
|
async fn record_stage1_output_usage_for_memory_citation(
|
|
state_db_ctx: Option<&codex_state::MemoryStore>,
|
|
memory_citation: &MemoryCitation,
|
|
) -> bool {
|
|
let thread_ids = thread_ids_from_memory_citation(memory_citation);
|
|
if thread_ids.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
if let Some(db) = state_db_ctx {
|
|
let _ = db.record_stage1_output_usage(&thread_ids).await;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Handle a completed output item from the model stream, recording it and
|
|
/// queuing any tool execution futures. This records items immediately so
|
|
/// history and rollout stay in sync even if the turn is later cancelled.
|
|
pub(crate) type InFlightFuture<'f> =
|
|
Pin<Box<dyn Future<Output = Result<ResponseItemEnvelope>> + Send + 'f>>;
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct OutputItemResult {
|
|
pub last_agent_message: Option<String>,
|
|
pub needs_follow_up: bool,
|
|
pub tool_future: Option<InFlightFuture<'static>>,
|
|
}
|
|
|
|
pub(crate) struct HandleOutputCtx {
|
|
pub sess: Arc<Session>,
|
|
pub step_context: Arc<StepContext>,
|
|
pub turn_store: Arc<ExtensionData>,
|
|
pub tool_runtime: ToolCallRuntime,
|
|
pub defer_tool_dispatch: bool,
|
|
pub cancellation_token: CancellationToken,
|
|
}
|
|
|
|
pub(crate) async fn apply_turn_item_contributors(
|
|
sess: &Session,
|
|
turn_store: &ExtensionData,
|
|
item: &mut TurnItem,
|
|
) {
|
|
let contributors = sess.services.extensions.turn_item_contributors().to_vec();
|
|
for contributor in contributors {
|
|
if let Err(err) = contributor
|
|
.contribute(&sess.services.thread_extension_data, turn_store, item)
|
|
.await
|
|
{
|
|
warn!("turn item contributor failed: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) enum TurnItemContributorPolicy<'a> {
|
|
Skip,
|
|
Run(&'a ExtensionData),
|
|
}
|
|
|
|
pub(crate) struct FinalizedTurnItem {
|
|
pub(crate) turn_item: TurnItem,
|
|
pub(crate) facts: FinalizedTurnItemFacts,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct FinalizedTurnItemFacts {
|
|
pub(crate) memory_citation: Option<MemoryCitation>,
|
|
pub(crate) last_agent_message: Option<String>,
|
|
pub(crate) defers_mailbox_delivery_to_next_turn: bool,
|
|
}
|
|
|
|
pub(crate) async fn finalize_non_tool_response_item(
|
|
sess: &Session,
|
|
contributor_policy: TurnItemContributorPolicy<'_>,
|
|
item: &ResponseItem,
|
|
plan_mode: bool,
|
|
) -> Option<FinalizedTurnItem> {
|
|
let turn_item =
|
|
handle_non_tool_response_item(sess, contributor_policy, item, plan_mode).await?;
|
|
let (memory_citation, last_agent_message, defers_mailbox_delivery_to_next_turn) =
|
|
match &turn_item {
|
|
TurnItem::AgentMessage(agent_message) => {
|
|
let combined = agent_message
|
|
.content
|
|
.iter()
|
|
.map(|entry| match entry {
|
|
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
|
})
|
|
.collect::<String>();
|
|
let last_agent_message = if combined.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(combined)
|
|
};
|
|
let defers_mailbox_delivery_to_next_turn =
|
|
assistant_message_defers_mailbox_delivery(agent_message.phase.as_ref(), || {
|
|
last_agent_message.is_some()
|
|
});
|
|
(
|
|
agent_message.memory_citation.clone(),
|
|
last_agent_message,
|
|
defers_mailbox_delivery_to_next_turn,
|
|
)
|
|
}
|
|
_ => (None, None, false),
|
|
};
|
|
Some(FinalizedTurnItem {
|
|
turn_item,
|
|
facts: FinalizedTurnItemFacts {
|
|
memory_citation,
|
|
last_agent_message,
|
|
defers_mailbox_delivery_to_next_turn,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub(crate) async fn handle_output_item_done(
|
|
ctx: &mut HandleOutputCtx,
|
|
item: ResponseItem,
|
|
previously_active_item: Option<TurnItem>,
|
|
) -> Result<OutputItemResult> {
|
|
let mut output = OutputItemResult::default();
|
|
let plan_mode = ctx.step_context.turn.mode() == ModeKind::Plan;
|
|
|
|
match ctx.step_context.tool_router.build_tool_call(item.clone()) {
|
|
// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
|
|
Ok(Some(call)) => {
|
|
call_trace::received(
|
|
ctx.sess.thread_id,
|
|
&call.tool_name,
|
|
&call.call_id,
|
|
call_trace::Receipt::ModelTurn(&ctx.step_context.turn.sub_id),
|
|
);
|
|
ctx.sess
|
|
.input_queue
|
|
.accept_mailbox_delivery_for_current_turn(
|
|
&ctx.sess.active_turn,
|
|
&ctx.step_context.turn.sub_id,
|
|
)
|
|
.await;
|
|
|
|
tracing::info!(
|
|
thread_id = %ctx.sess.thread_id,
|
|
"ToolCall: {}",
|
|
call.tool_name,
|
|
);
|
|
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.step_context.as_ref(), &item)
|
|
.await;
|
|
|
|
let cancellation_token = ctx.cancellation_token.child_token();
|
|
let runtime = ctx.tool_runtime.clone();
|
|
// handle_tool_call dispatches immediately, not when its future is polled.
|
|
// Defer for post-model hooks or to cancel buffered calls during mailbox drains.
|
|
let tool_future: InFlightFuture<'static> = if ctx.defer_tool_dispatch {
|
|
Box::pin(async move {
|
|
if cancellation_token.is_cancelled() {
|
|
// The call is already in history; resume needs its output even if we never dispatch.
|
|
let output =
|
|
ToolCallRuntime::aborted_response(&call, /*elapsed_secs*/ None);
|
|
return Ok(output.into_response());
|
|
}
|
|
runtime.handle_tool_call(call, cancellation_token).await
|
|
})
|
|
} else {
|
|
Box::pin(runtime.handle_tool_call(call, cancellation_token))
|
|
};
|
|
|
|
output.needs_follow_up = true;
|
|
output.tool_future = Some(tool_future);
|
|
}
|
|
// No tool call: convert messages/reasoning into turn items and mark them as complete.
|
|
Ok(None) => {
|
|
ctx.sess
|
|
.services
|
|
.executed_tool_calls
|
|
.observe_non_dispatched_call(&item);
|
|
let finalized_turn_item = finalize_non_tool_response_item(
|
|
ctx.sess.as_ref(),
|
|
TurnItemContributorPolicy::Run(ctx.turn_store.as_ref()),
|
|
&item,
|
|
plan_mode,
|
|
)
|
|
.await;
|
|
let finalized_facts = finalized_turn_item
|
|
.as_ref()
|
|
.map(|finalized| finalized.facts.clone());
|
|
if let Some(finalized_turn_item) = finalized_turn_item {
|
|
if previously_active_item.is_none() {
|
|
ctx.sess
|
|
.emit_turn_item_started(
|
|
&ctx.step_context.turn,
|
|
&finalized_turn_item.turn_item,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
ctx.sess
|
|
.emit_turn_item_completed(&ctx.step_context.turn, finalized_turn_item.turn_item)
|
|
.await;
|
|
}
|
|
record_completed_response_item_with_finalized_facts(
|
|
ctx.sess.as_ref(),
|
|
ctx.step_context.as_ref(),
|
|
&item,
|
|
finalized_facts.as_ref(),
|
|
)
|
|
.await;
|
|
|
|
output.last_agent_message = finalized_facts.and_then(|facts| facts.last_agent_message);
|
|
}
|
|
// The tool request should be answered directly (or was denied); push that response into the transcript.
|
|
Err(FunctionCallError::RespondToModel(message)) => {
|
|
ctx.sess
|
|
.services
|
|
.executed_tool_calls
|
|
.observe_non_dispatched_call(&item);
|
|
let response = ResponseInputItem::FunctionCallOutput {
|
|
call_id: String::new(),
|
|
output: FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(message),
|
|
..Default::default()
|
|
},
|
|
};
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.step_context.as_ref(), &item)
|
|
.await;
|
|
if let Some(response_item) = response_input_to_response_item(&response) {
|
|
ctx.sess
|
|
.record_conversation_items(
|
|
&ctx.step_context.turn,
|
|
&ctx.step_context.settings.model_info,
|
|
std::slice::from_ref(&response_item),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
output.needs_follow_up = true;
|
|
}
|
|
// A fatal error occurred; surface it back into history.
|
|
Err(FunctionCallError::Fatal(message)) => {
|
|
return Err(CodexErr::Fatal(message));
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
pub(crate) async fn handle_non_tool_response_item(
|
|
sess: &Session,
|
|
contributor_policy: TurnItemContributorPolicy<'_>,
|
|
item: &ResponseItem,
|
|
plan_mode: bool,
|
|
) -> Option<TurnItem> {
|
|
debug!(
|
|
item_type = item.item_type(),
|
|
item_id = item.id().map(ResponseItemId::as_str),
|
|
"Output item"
|
|
);
|
|
|
|
match item {
|
|
ResponseItem::Message { .. }
|
|
| ResponseItem::Reasoning { .. }
|
|
| ResponseItem::WebSearchCall { .. } => {
|
|
let mut turn_item = parse_turn_item(item)?;
|
|
finalize_turn_item(sess, contributor_policy, &mut turn_item, plan_mode).await;
|
|
Some(turn_item)
|
|
}
|
|
ResponseItem::FunctionCallOutput { .. }
|
|
| ResponseItem::CustomToolCallOutput { .. }
|
|
| ResponseItem::ToolSearchOutput { .. } => {
|
|
debug!("unexpected tool output from stream");
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn finalize_turn_item(
|
|
sess: &Session,
|
|
contributor_policy: TurnItemContributorPolicy<'_>,
|
|
turn_item: &mut TurnItem,
|
|
plan_mode: bool,
|
|
) {
|
|
if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy {
|
|
apply_turn_item_contributors(sess, turn_store, turn_item).await;
|
|
}
|
|
if let TurnItem::AgentMessage(agent_message) = &mut *turn_item {
|
|
let combined = agent_message
|
|
.content
|
|
.iter()
|
|
.map(|entry| match entry {
|
|
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
|
|
})
|
|
.collect::<String>();
|
|
let (stripped, memory_citation) =
|
|
strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode);
|
|
agent_message.content =
|
|
vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }];
|
|
if agent_message.memory_citation.is_none() {
|
|
agent_message.memory_citation = memory_citation;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn last_assistant_message_from_item(
|
|
item: &ResponseItem,
|
|
plan_mode: bool,
|
|
) -> Option<String> {
|
|
if let Some(combined) = raw_assistant_output_text_from_item(item) {
|
|
if combined.is_empty() {
|
|
return None;
|
|
}
|
|
let stripped = strip_hidden_assistant_markup(&combined, plan_mode);
|
|
if stripped.trim().is_empty() {
|
|
return None;
|
|
}
|
|
return Some(stripped);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn completed_item_defers_mailbox_delivery_to_next_turn(
|
|
item: &ResponseItem,
|
|
plan_mode: bool,
|
|
) -> bool {
|
|
match item {
|
|
ResponseItem::Message { role, phase, .. } if role == "assistant" => {
|
|
assistant_message_defers_mailbox_delivery(phase.as_ref(), || {
|
|
last_assistant_message_from_item(item, plan_mode).is_some()
|
|
})
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn assistant_message_defers_mailbox_delivery(
|
|
phase: Option<&MessagePhase>,
|
|
has_visible_text: impl FnOnce() -> bool,
|
|
) -> bool {
|
|
match phase {
|
|
Some(MessagePhase::FinalAnswer) => true,
|
|
Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) => false,
|
|
// Without an explicit phase, keep treating visible assistant text as a final answer.
|
|
None => has_visible_text(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Option<ResponseItem> {
|
|
match input {
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
Some(ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some(call_id.clone()),
|
|
name: None,
|
|
namespace: None,
|
|
output: output.clone(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
})
|
|
}
|
|
ResponseInputItem::CustomToolCallOutput {
|
|
call_id,
|
|
name,
|
|
output,
|
|
} => Some(ResponseItem::CustomToolCallOutput {
|
|
id: None,
|
|
call_id: call_id.clone(),
|
|
name: name.clone(),
|
|
output: output.clone(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}),
|
|
ResponseInputItem::McpToolCallOutput { call_id, output } => {
|
|
let output = output.as_function_call_output_payload();
|
|
Some(ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some(call_id.clone()),
|
|
name: None,
|
|
namespace: None,
|
|
output,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
})
|
|
}
|
|
ResponseInputItem::ToolSearchOutput {
|
|
call_id,
|
|
status,
|
|
execution,
|
|
tools,
|
|
} => Some(ResponseItem::ToolSearchOutput {
|
|
id: None,
|
|
call_id: Some(call_id.clone()),
|
|
status: status.clone(),
|
|
execution: execution.clone(),
|
|
tools: tools.clone(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "stream_events_utils_tests.rs"]
|
|
mod tests;
|