use anyhow::Context;use anyhow::Result;use anyhow::bail;use super::TraceReducer;use crate::model::CodeModeRuntimeToolId;use crate::model::ConversationItemKind;use crate::model::ExecutionStatus;use crate::model::ExecutionWindow;use crate::model::McpCallId;use crate::model::ModelVisibleCallId;use crate::model::ProducerRef;use crate::model::ToolCall;use crate::model::ToolCallId;use crate::model::ToolCallKind;use crate::model::ToolCallSummary;use crate::payload::RawPayloadRef;use crate::raw_event::RawEventSeq;use crate::raw_event::RawToolCallRequester;mod agents;mod terminal;pub(super) use agents::ObservedAgentResultEdge;pub(super) use agents::PendingAgentInteractionEdge;pub(super) use agents::spawn_edge_id;/// Raw tool-start fields after dispatch has stripped the common event envelope.////// Tool starts carry several optional identity namespaces: model-visible calls,/// code-mode runtime tools, and canonical invocation payloads. Grouping them keeps/// the reducer callsite readable and avoids positional argument mistakes.pub(super) struct ToolCallStarted { pub(super) tool_call_id: ToolCallId, pub(super) model_visible_call_id: Option<ModelVisibleCallId>, pub(super) code_mode_runtime_tool_id: Option<CodeModeRuntimeToolId>, pub(super) requester: RawToolCallRequester, pub(super) kind: ToolCallKind, pub(super) summary: ToolCallSummary, pub(super) invocation_payload: Option<RawPayloadRef>,}impl TraceReducer { /// Starts a tool call and links it to model-visible items or runtime parents when available. /// /// Some tools also create richer domain objects, such as terminal operations, from /// the same invocation payload. The generic ToolCall remains the common index. pub(super) fn start_tool_call( &mut self, seq: RawEventSeq, wall_time_unix_ms: i64, thread_id: Option<String>, codex_turn_id: Option<String>, started: ToolCallStarted, ) -> Result<()> { let tool_call_id = started.tool_call_id.clone(); if self.rollout.tool_calls.contains_key(&tool_call_id) { bail!("duplicate tool call start for {tool_call_id}"); } self.ensure_unique_model_visible_tool_call( started.model_visible_call_id.as_deref(), &tool_call_id, )?; let thread_id = self.tool_thread_id(thread_id, codex_turn_id.as_deref())?; self.validate_tool_turn(&thread_id, codex_turn_id.as_deref())?; let model_visible_call_id = started.model_visible_call_id.clone(); let requester = self.reduce_tool_call_requester(&thread_id, started.requester.clone())?; let model_visible_call_item_ids = model_visible_call_id .as_deref() .map(|call_id| { self.model_visible_tool_item_ids( &thread_id, call_id, &[ ConversationItemKind::FunctionCall, ConversationItemKind::CustomToolCall, ], ) }) .unwrap_or_default(); let model_visible_output_item_ids = model_visible_call_id .as_deref() .map(|call_id| { self.model_visible_tool_item_ids( &thread_id, call_id, &[ ConversationItemKind::FunctionCallOutput, ConversationItemKind::CustomToolCallOutput, ], ) }) .unwrap_or_default(); self.thread_mut(&thread_id)?; // Some terminal-like tools, notably write_stdin, do not emit a richer // runtime begin event. For those tools the canonical invocation is the // only place to recover the terminal/session join key. let terminal_operation_id = self.start_terminal_operation_from_invocation( seq, wall_time_unix_ms, &thread_id, &tool_call_id, &started.kind, started.invocation_payload.as_ref(), )?; // Terminal-backed tools should render through the richer terminal // operation instead of the generic tool summary captured by producers. let summary = terminal_operation_id .as_ref() .map(|operation_id| ToolCallSummary::Terminal { operation_id: operation_id.clone(), }) .unwrap_or(started.summary); let raw_invocation_payload_id = started .invocation_payload .as_ref() .map(|payload| payload.raw_payload_id.clone()); self.link_wait_tool_call_from_request_payload( &thread_id, &tool_call_id, started.invocation_payload.as_ref(), )?; self.rollout.tool_calls.insert( tool_call_id.clone(), ToolCall { tool_call_id: tool_call_id.clone(), mcp_call_id: None, model_visible_call_id, code_mode_runtime_tool_id: started.code_mode_runtime_tool_id, thread_id, started_by_codex_turn_id: codex_turn_id, execution: ExecutionWindow { started_at_unix_ms: wall_time_unix_ms, started_seq: seq, ended_at_unix_ms: None, ended_seq: None, status: ExecutionStatus::Running, }, requester: requester.clone(), kind: started.kind, model_visible_call_item_ids, model_visible_output_item_ids: Vec::new(), terminal_operation_id, summary, raw_invocation_payload_id, raw_result_payload_id: None, raw_runtime_payload_ids: Vec::new(), }, ); self.link_tool_call_to_code_cell(&tool_call_id, &requester)?; self.link_tool_to_inference_response(&tool_call_id); // Output items need the reverse ProducerRef edge as well, so attach // them after insertion through the same helper used by the transcript // reducer when the output is observed after the tool start. for item_id in model_visible_output_item_ids { self.add_tool_output_item(&tool_call_id, &item_id)?; } // The call/output items may have been observed before this tool start. // Re-sync after insertion so terminal observations get both directions // of the model-visible link. self.sync_terminal_model_observation(&tool_call_id)?; Ok(()) } /// Attaches the bridge-visible MCP UUID after the generic tool call exists. pub(super) fn assign_mcp_tool_call_correlation( &mut self, tool_call_id: ToolCallId, mcp_call_id: McpCallId, ) -> Result<()> { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("MCP correlation referenced unknown tool call {tool_call_id}"); }; if tool_call.mcp_call_id.replace(mcp_call_id).is_some() { bail!("duplicate MCP correlation for tool call {tool_call_id}"); } Ok(()) } /// Completes the canonical tool call and any terminal operation driven by dispatch output. /// /// Protocol-backed terminal tools end from runtime events; direct tools /// may only have the canonical result payload, so this method handles both paths. pub(super) fn end_tool_call( &mut self, seq: RawEventSeq, wall_time_unix_ms: i64, tool_call_id: ToolCallId, status: ExecutionStatus, result_payload: Option<RawPayloadRef>, ) -> Result<()> { let (terminal_operation_id, thread_id, end_terminal_from_result) = { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("tool call end referenced unknown call {tool_call_id}"); }; tool_call.execution.ended_at_unix_ms = Some(wall_time_unix_ms); tool_call.execution.ended_seq = Some(seq); tool_call.execution.status = status.clone(); tool_call.raw_result_payload_id = result_payload .as_ref() .map(|payload| payload.raw_payload_id.clone()); ( tool_call.terminal_operation_id.clone(), tool_call.thread_id.clone(), // Protocol-backed tools end terminal operations from // runtime observations. Dispatch result payloads are still kept // on ToolCall, but they are caller-facing and may be transformed // relative to the raw terminal output. tool_call.raw_runtime_payload_ids.is_empty(), ) }; if end_terminal_from_result && let Some(operation_id) = terminal_operation_id { self.end_terminal_operation( seq, wall_time_unix_ms, &thread_id, &operation_id, status, result_payload.as_ref(), )?; } self.attach_agent_interaction_tool_result(&tool_call_id, result_payload.as_ref())?; Ok(()) } /// Records a runtime-begin observation for an already started tool call. /// /// Runtime observations enrich the generic tool with protocol facts and may /// create domain-specific children such as terminal operations or agent edges. pub(super) fn start_tool_runtime_observation( &mut self, seq: RawEventSeq, wall_time_unix_ms: i64, tool_call_id: ToolCallId, runtime_payload: RawPayloadRef, ) -> Result<()> { let (thread_id, _requester, kind, existing_terminal_operation_id) = { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("tool runtime start referenced unknown call {tool_call_id}"); }; push_unique( &mut tool_call.raw_runtime_payload_ids, &runtime_payload.raw_payload_id, ); ( tool_call.thread_id.clone(), tool_call.requester.clone(), tool_call.kind.clone(), tool_call.terminal_operation_id.clone(), ) }; if existing_terminal_operation_id.is_some() && matches!(kind, ToolCallKind::ExecCommand | ToolCallKind::WriteStdin) { bail!("tool runtime start would create a second terminal operation for {tool_call_id}"); } // Protocol begin events carry runtime facts such as process ids and // cwd. These facts should create terminal rows, but they must not // replace the canonical invocation payload captured at dispatch. let terminal_operation_id = self.start_terminal_operation_from_runtime( seq, wall_time_unix_ms, &thread_id, &tool_call_id, &kind, &runtime_payload, )?; if let Some(operation_id) = &terminal_operation_id { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("tool call {tool_call_id} disappeared during runtime start reduction"); }; if tool_call.terminal_operation_id.is_none() { tool_call.terminal_operation_id = Some(operation_id.clone()); tool_call.summary = ToolCallSummary::Terminal { operation_id: operation_id.clone(), }; } } if terminal_operation_id.is_some() { self.sync_terminal_model_observation(&tool_call_id)?; } self.start_agent_interaction_from_runtime(&tool_call_id, &runtime_payload)?; Ok(()) } /// Records a runtime-end observation for an already started tool call. pub(super) fn end_tool_runtime_observation( &mut self, seq: RawEventSeq, wall_time_unix_ms: i64, tool_call_id: ToolCallId, status: ExecutionStatus, runtime_payload: RawPayloadRef, ) -> Result<()> { let (thread_id, terminal_operation_id) = { let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else { bail!("tool runtime end referenced unknown call {tool_call_id}"); }; push_unique( &mut tool_call.raw_runtime_payload_ids, &runtime_payload.raw_payload_id, ); ( tool_call.thread_id.clone(), tool_call.terminal_operation_id.clone(), ) }; if let Some(operation_id) = terminal_operation_id { self.end_terminal_operation( seq, wall_time_unix_ms, &thread_id, &operation_id, status, Some(&runtime_payload), )?; } self.end_agent_interaction_from_runtime( wall_time_unix_ms, &tool_call_id, &runtime_payload, )?; Ok(()) } /// Attaches a conversation item observed after the tool call was reduced. /// /// Inference request/response ordering can expose call/output items after the /// runtime tool object exists, so transcript reduction calls back here to add /// reverse links without duplicating matching logic. pub(super) fn attach_model_visible_tool_item( &mut self, item_id: &str, call_id: Option<&str>, kind: &ConversationItemKind, ) -> Result<()> { let Some(call_id) = call_id else { return Ok(()); }; match kind { ConversationItemKind::FunctionCall | ConversationItemKind::CustomToolCall => { if let Some(tool_call_id) = self.single_tool_for_model_visible_call(call_id)? { self.add_tool_call_item(&tool_call_id, item_id)?; self.link_tool_to_inference_response(&tool_call_id); self.sync_terminal_model_observation(&tool_call_id)?; } } ConversationItemKind::FunctionCallOutput | ConversationItemKind::CustomToolCallOutput => { if let Some(tool_call_id) = self.single_tool_for_model_visible_call(call_id)? { self.add_tool_output_item(&tool_call_id, item_id)?; self.sync_terminal_model_observation(&tool_call_id)?; } } ConversationItemKind::Message | ConversationItemKind::Reasoning | ConversationItemKind::CompactionMarker => {} } Ok(()) } fn tool_thread_id( &self, thread_id: Option<String>, codex_turn_id: Option<&str>, ) -> Result<String> { if let Some(thread_id) = thread_id { return Ok(thread_id); } let Some(codex_turn_id) = codex_turn_id else { bail!("tool call start did not include thread or Codex turn context"); }; self.rollout .codex_turns .get(codex_turn_id) .map(|turn| turn.thread_id.clone()) .with_context(|| { format!("tool call start referenced unknown Codex turn {codex_turn_id}") }) } fn validate_tool_turn(&self, thread_id: &str, codex_turn_id: Option<&str>) -> Result<()> { if !self.rollout.threads.contains_key(thread_id) { bail!("tool call start referenced unknown thread {thread_id}"); } if let Some(codex_turn_id) = codex_turn_id { let Some(turn) = self.rollout.codex_turns.get(codex_turn_id) else { bail!("tool call start referenced unknown Codex turn {codex_turn_id}"); }; if turn.thread_id != thread_id { bail!( "tool call start used thread {thread_id}, but Codex turn {codex_turn_id} \ belongs to {}", turn.thread_id ); } } Ok(()) } fn ensure_unique_model_visible_tool_call( &self, model_visible_call_id: Option<&str>, tool_call_id: &str, ) -> Result<()> { let Some(model_visible_call_id) = model_visible_call_id else { return Ok(()); }; if let Some(existing) = self.single_tool_for_model_visible_call(model_visible_call_id)? && existing != tool_call_id { bail!("duplicate tool call for model-visible call id {model_visible_call_id}"); } Ok(()) } fn single_tool_for_model_visible_call( &self, model_visible_call_id: &str, ) -> Result<Option<ToolCallId>> { let mut matching = self .rollout .tool_calls .values() .filter(|tool| tool.model_visible_call_id.as_deref() == Some(model_visible_call_id)) .map(|tool| tool.tool_call_id.clone()); let first = matching.next(); if matching.next().is_some() { bail!("multiple tool calls matched model-visible call id {model_visible_call_id}"); } Ok(first) } fn model_visible_tool_item_ids( &self, thread_id: &str, call_id: &str, kinds: &[ConversationItemKind], ) -> Vec<String> { self.rollout .conversation_items .values() .filter(|item| { item.thread_id == thread_id && item.call_id.as_deref() == Some(call_id) && kinds.contains(&item.kind) }) .map(|item| item.item_id.clone()) .collect::<Vec<_>>() } fn add_tool_call_item(&mut self, tool_call_id: &str, item_id: &str) -> Result<()> { let Some(tool_call) = self.rollout.tool_calls.get_mut(tool_call_id) else { bail!("tool call {tool_call_id} disappeared during conversation linking"); }; push_unique(&mut tool_call.model_visible_call_item_ids, item_id); Ok(()) } fn add_tool_output_item(&mut self, tool_call_id: &str, item_id: &str) -> Result<()> { let Some(tool_call) = self.rollout.tool_calls.get_mut(tool_call_id) else { bail!("tool call {tool_call_id} disappeared during output linking"); }; push_unique(&mut tool_call.model_visible_output_item_ids, item_id); let Some(item) = self.rollout.conversation_items.get_mut(item_id) else { bail!("conversation item {item_id} disappeared during output linking"); }; let producer = ProducerRef::Tool { tool_call_id: tool_call_id.to_string(), }; if !item.produced_by.contains(&producer) { item.produced_by.push(producer); } Ok(()) } fn link_tool_to_inference_response(&mut self, tool_call_id: &str) { let Some(tool_call) = self.rollout.tool_calls.get(tool_call_id) else { return; }; let call_item_ids = tool_call.model_visible_call_item_ids.clone(); if call_item_ids.is_empty() { return; } for inference in self.rollout.inference_calls.values_mut() { if inference .response_item_ids .iter() .any(|item_id| call_item_ids.contains(item_id)) && !inference .tool_call_ids_started_by_response .contains(&tool_call_id.to_string()) { inference .tool_call_ids_started_by_response .push(tool_call_id.to_string()); } } }}fn push_unique(items: &mut Vec<String>, item_id: &str) { if !items.iter().any(|existing| existing == item_id) { items.push(item_id.to_string()); }}