mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## Why Direct tool-call records need to stay associated with the invocation that produced each output, including when call IDs are reused. Completeness must describe the recorded call inventory, independently of tool success. ## What changed - Attach direct-call records to outputs before they enter history, and set `tool_calls_complete` when the invocation's arguments are fully recorded. - Bound pending recordings and retained metadata, release reservations on completion or cancellation, and invalidate pending records when capture is disabled. - Apply request budgets to direct metadata and strip it from inference and compaction inputs when capture is disabled. - Remove executed-call metadata from app-server raw response notifications and exclude its size from Guardian history retention budgets. - Track call IDs that bypass dispatch so their reuse cannot incorrectly establish Code Mode completeness. ## Testing Add regression coverage for direct-call attribution, malformed calls, metadata budgets, cancellation, configuration changes, compaction, notification filtering, and Guardian context isolation. GitOrigin-RevId: 2ebd39c7f141d04788736491495109841656b4c0
587 lines
20 KiB
Rust
587 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 crate::tools::router::ToolRouter;
|
|
use crate::tools::router::tool_log_payload;
|
|
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;
|
|
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 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 =
|
|
!matches!(agent_message.phase, Some(MessagePhase::Commentary))
|
|
&& 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 ToolRouter::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;
|
|
|
|
let payload_preview = tool_log_payload(&call.payload, &call.direct_source());
|
|
tracing::info!(
|
|
thread_id = %ctx.sess.thread_id,
|
|
"ToolCall: {} {}",
|
|
call.tool_name,
|
|
payload_preview
|
|
);
|
|
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.step_context.as_ref(), &item)
|
|
.await;
|
|
|
|
let cancellation_token = ctx.cancellation_token.child_token();
|
|
let tool_future: InFlightFuture<'static> = Box::pin(
|
|
ctx.tool_runtime
|
|
.clone()
|
|
.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> {
|
|
let item_type = match item {
|
|
ResponseItem::AdditionalTools { .. } => "additional_tools",
|
|
ResponseItem::Message { .. } => "message",
|
|
ResponseItem::AgentMessage { .. } => "agent_message",
|
|
ResponseItem::Reasoning { .. } => "reasoning",
|
|
ResponseItem::LocalShellCall { .. } => "local_shell_call",
|
|
ResponseItem::FunctionCall { .. } => "function_call",
|
|
ResponseItem::ToolSearchCall { .. } => "tool_search_call",
|
|
ResponseItem::FunctionCallOutput { .. } => "function_call_output",
|
|
ResponseItem::CustomToolCall { .. } => "custom_tool_call",
|
|
ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output",
|
|
ResponseItem::ToolSearchOutput { .. } => "tool_search_output",
|
|
ResponseItem::WebSearchCall { .. } => "web_search_call",
|
|
ResponseItem::ImageGenerationCall { .. } => "image_generation_call",
|
|
ResponseItem::Compaction { .. } => "compaction",
|
|
ResponseItem::ConfigurationUpdate { .. } => "configuration_update",
|
|
ResponseItem::CompactionTrigger { .. } => "compaction_trigger",
|
|
ResponseItem::ContextCompaction { .. } => "context_compaction",
|
|
ResponseItem::Other => "other",
|
|
};
|
|
debug!(
|
|
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" || matches!(phase, Some(MessagePhase::Commentary)) {
|
|
return false;
|
|
}
|
|
// Treat `None` like final-answer text so untagged providers default
|
|
// to the safer "defer mailbox mail" behavior.
|
|
last_assistant_message_from_item(item, plan_mode).is_some()
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
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;
|