mirror of
https://github.com/openai/codex.git
synced 2026-05-18 18:22:39 +00:00
Start from the pinned `origin/dev/friel/collab-stack` snapshot and fold in the local follow-up repairs that made that imported stack usable here: watchdog spawn/registration plumbing, deferred-tool fallback behavior, collab discovery fixture alignment, schema mirroring, and subagent-panel fixes. Original imported source: - source ref: `refs/remotes/origin/dev/friel/collab-stack` - source tip: `599ed9dc05eafd116192184bd54a2a55a2c49366` - original base: `c1d18ceb6f22ae3acd67bbd6badad0f475b31dfc`
521 lines
18 KiB
Rust
521 lines
18 KiB
Rust
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
|
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::context::ContextualUserFragment;
|
|
use crate::context::ImageGenerationInstructions;
|
|
use crate::function_tool::FunctionCallError;
|
|
use crate::parse_turn_item;
|
|
use crate::session::session::Session;
|
|
use crate::session::turn_context::TurnContext;
|
|
use crate::tools::parallel::ToolCallRuntime;
|
|
use crate::tools::router::ToolRouter;
|
|
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::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_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_stream_parser::strip_proposed_plan_blocks;
|
|
use futures::Future;
|
|
use tracing::debug;
|
|
use tracing::instrument;
|
|
|
|
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
|
|
|
|
pub(crate) fn image_generation_artifact_path(
|
|
codex_home: &AbsolutePathBuf,
|
|
session_id: &str,
|
|
call_id: &str,
|
|
) -> AbsolutePathBuf {
|
|
let sanitize = |value: &str| {
|
|
let mut sanitized: String = value
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
|
ch
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
if sanitized.is_empty() {
|
|
sanitized = "generated_image".to_string();
|
|
}
|
|
sanitized
|
|
};
|
|
|
|
codex_home
|
|
.join(GENERATED_IMAGE_ARTIFACTS_DIR)
|
|
.join(sanitize(session_id))
|
|
.join(format!("{}.png", sanitize(call_id)))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async fn save_image_generation_result(
|
|
codex_home: &AbsolutePathBuf,
|
|
session_id: &str,
|
|
call_id: &str,
|
|
result: &str,
|
|
) -> Result<AbsolutePathBuf> {
|
|
let bytes = BASE64_STANDARD
|
|
.decode(result.trim().as_bytes())
|
|
.map_err(|err| {
|
|
CodexErr::InvalidRequest(format!("invalid image generation payload: {err}"))
|
|
})?;
|
|
let path = image_generation_artifact_path(codex_home, session_id, call_id);
|
|
if let Some(parent) = path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
tokio::fs::write(&path, bytes).await?;
|
|
Ok(path)
|
|
}
|
|
|
|
/// Persist a completed model response item and record any cited memory usage.
|
|
pub(crate) async fn record_completed_response_item(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
item: &ResponseItem,
|
|
) {
|
|
sess.record_conversation_items(turn_context, std::slice::from_ref(item))
|
|
.await;
|
|
if completed_item_defers_mailbox_delivery_to_next_turn(
|
|
item,
|
|
turn_context.collaboration_mode.mode == ModeKind::Plan,
|
|
) {
|
|
sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id)
|
|
.await;
|
|
}
|
|
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
|
|
let has_memory_citation =
|
|
record_stage1_output_usage_and_detect_memory_citation(turn_context, 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 { .. }
|
|
)
|
|
}
|
|
|
|
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.conversation_id,
|
|
"record_completed_response_item",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_stage1_output_usage_and_detect_memory_citation(
|
|
turn_context: &TurnContext,
|
|
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;
|
|
};
|
|
let thread_ids = thread_ids_from_memory_citation(&memory_citation);
|
|
if thread_ids.is_empty() {
|
|
return true;
|
|
}
|
|
|
|
if let Some(db) = state_db::get_state_db(turn_context.config.as_ref()).await {
|
|
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<ResponseInputItem>> + 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 turn_context: Arc<TurnContext>,
|
|
pub tool_runtime: ToolCallRuntime,
|
|
pub cancellation_token: CancellationToken,
|
|
}
|
|
|
|
#[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.turn_context.collaboration_mode.mode == ModeKind::Plan;
|
|
|
|
match ToolRouter::build_tool_call(ctx.sess.as_ref(), item.clone()).await {
|
|
// The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
|
|
Ok(Some(call)) => {
|
|
ctx.sess
|
|
.accept_mailbox_delivery_for_current_turn(&ctx.turn_context.sub_id)
|
|
.await;
|
|
|
|
let payload_preview = call.payload.log_payload().into_owned();
|
|
tracing::info!(
|
|
thread_id = %ctx.sess.conversation_id,
|
|
"ToolCall: {} {}",
|
|
call.tool_name.display(),
|
|
payload_preview
|
|
);
|
|
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_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) => {
|
|
if let Some(turn_item) = handle_non_tool_response_item(
|
|
ctx.sess.as_ref(),
|
|
ctx.turn_context.as_ref(),
|
|
&item,
|
|
plan_mode,
|
|
)
|
|
.await
|
|
{
|
|
if previously_active_item.is_none() {
|
|
let mut started_item = turn_item.clone();
|
|
if let TurnItem::ImageGeneration(item) = &mut started_item {
|
|
item.status = "in_progress".to_string();
|
|
item.revised_prompt = None;
|
|
item.result.clear();
|
|
item.saved_path = None;
|
|
}
|
|
ctx.sess
|
|
.emit_turn_item_started(&ctx.turn_context, &started_item)
|
|
.await;
|
|
}
|
|
|
|
ctx.sess
|
|
.emit_turn_item_completed(&ctx.turn_context, turn_item)
|
|
.await;
|
|
}
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
|
|
.await;
|
|
let last_agent_message = last_assistant_message_from_item(&item, plan_mode);
|
|
|
|
output.last_agent_message = last_agent_message;
|
|
}
|
|
// Guardrail: the model issued a LocalShellCall without an id; surface the error back into history.
|
|
Err(FunctionCallError::MissingLocalShellCallId) => {
|
|
let msg = "LocalShellCall without call_id or id";
|
|
ctx.turn_context
|
|
.session_telemetry
|
|
.log_tool_failed("local_shell", msg);
|
|
tracing::error!(msg);
|
|
|
|
let response = ResponseInputItem::FunctionCallOutput {
|
|
call_id: String::new(),
|
|
output: FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(msg.to_string()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
|
|
.await;
|
|
if let Some(response_item) = response_input_to_response_item(&response) {
|
|
ctx.sess
|
|
.record_conversation_items(
|
|
&ctx.turn_context,
|
|
std::slice::from_ref(&response_item),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
output.needs_follow_up = true;
|
|
}
|
|
// 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.turn_context.as_ref(), &item)
|
|
.await;
|
|
if let Some(response_item) = response_input_to_response_item(&response) {
|
|
ctx.sess
|
|
.record_conversation_items(
|
|
&ctx.turn_context,
|
|
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,
|
|
turn_context: &TurnContext,
|
|
item: &ResponseItem,
|
|
plan_mode: bool,
|
|
) -> Option<TurnItem> {
|
|
debug!(?item, "Output item");
|
|
|
|
match item {
|
|
ResponseItem::Message { .. }
|
|
| ResponseItem::Reasoning { .. }
|
|
| ResponseItem::WebSearchCall { .. }
|
|
| ResponseItem::ImageGenerationCall { .. } => {
|
|
let mut turn_item = parse_turn_item(item)?;
|
|
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 }];
|
|
agent_message.memory_citation = memory_citation;
|
|
}
|
|
if let TurnItem::ImageGeneration(image_item) = &mut turn_item {
|
|
let session_id = sess.conversation_id.to_string();
|
|
match save_image_generation_result(
|
|
&turn_context.config.codex_home,
|
|
&session_id,
|
|
&image_item.id,
|
|
&image_item.result,
|
|
)
|
|
.await
|
|
{
|
|
Ok(path) => {
|
|
image_item.saved_path = Some(path);
|
|
let image_output_path = image_generation_artifact_path(
|
|
&turn_context.config.codex_home,
|
|
&session_id,
|
|
"<image_id>",
|
|
);
|
|
let image_output_dir = image_output_path
|
|
.parent()
|
|
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
|
let message: ResponseItem =
|
|
ContextualUserFragment::into(ImageGenerationInstructions::new(
|
|
image_output_dir.display(),
|
|
image_output_path.display(),
|
|
));
|
|
sess.record_conversation_items(turn_context, &[message])
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
let output_path = image_generation_artifact_path(
|
|
&turn_context.config.codex_home,
|
|
&session_id,
|
|
&image_item.id,
|
|
);
|
|
let output_dir = output_path
|
|
.parent()
|
|
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
|
tracing::warn!(
|
|
call_id = %image_item.id,
|
|
output_dir = %output_dir.display(),
|
|
"failed to save generated image: {err}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Some(turn_item)
|
|
}
|
|
ResponseItem::FunctionCallOutput { .. }
|
|
| ResponseItem::CustomToolCallOutput { .. }
|
|
| ResponseItem::ToolSearchOutput { .. } => {
|
|
debug!("unexpected tool output from stream");
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
ResponseItem::ImageGenerationCall { .. } => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Option<ResponseItem> {
|
|
match input {
|
|
ResponseInputItem::FunctionCall {
|
|
name,
|
|
arguments,
|
|
call_id,
|
|
} => Some(ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: name.clone(),
|
|
namespace: None,
|
|
arguments: arguments.clone(),
|
|
call_id: call_id.clone(),
|
|
}),
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
Some(ResponseItem::FunctionCallOutput {
|
|
call_id: call_id.clone(),
|
|
output: output.clone(),
|
|
})
|
|
}
|
|
ResponseInputItem::CustomToolCallOutput {
|
|
call_id,
|
|
name,
|
|
output,
|
|
} => Some(ResponseItem::CustomToolCallOutput {
|
|
call_id: call_id.clone(),
|
|
name: name.clone(),
|
|
output: output.clone(),
|
|
}),
|
|
ResponseInputItem::McpToolCallOutput { call_id, output } => {
|
|
let output = output.as_function_call_output_payload();
|
|
Some(ResponseItem::FunctionCallOutput {
|
|
call_id: call_id.clone(),
|
|
output,
|
|
})
|
|
}
|
|
ResponseInputItem::ToolSearchOutput {
|
|
call_id,
|
|
status,
|
|
execution,
|
|
tools,
|
|
} => Some(ResponseItem::ToolSearchOutput {
|
|
call_id: Some(call_id.clone()),
|
|
status: status.clone(),
|
|
execution: execution.clone(),
|
|
tools: tools.clone(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "stream_events_utils_tests.rs"]
|
|
mod tests;
|