mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
## Why History recording used the model captured at turn start, so switching models within a turn could apply stale image preparation and tool-output truncation rules. ## What changed Pass explicit model metadata through conversation recording. Record model responses and tool results using their originating step's settings, and capture the current model for pending input recorded during startup interruption or task completion. ## Testing Add regression coverage for tool results across a model switch, explicit model selection for plain and annotated history, and image input retained during interruption and completion. Extend model-switching tests to verify that prepared media and item identity survive switching back to a multimodal model in live, resumed, and forked threads. GitOrigin-RevId: 5bc750c30c334acb9b802cb80fdea01daffa84a1
572 lines
19 KiB
Rust
572 lines
19 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::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)) => {
|
|
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) => {
|
|
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)) => {
|
|
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;
|