Download codex-rs/core/src/session/turn.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 120 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/session/turn.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/src/session/turn.rs
-
curl -L -o turn.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/session/turn.rs
120 kB
| use std::collections::HashMap; | |
| use std::collections::HashSet; | |
| use std::marker::PhantomData; | |
| use std::sync::Arc; | |
| use std::sync::atomic::Ordering; | |
| use crate::client::ModelClientSession; | |
| use crate::client_common::Prompt; | |
| use crate::client_common::ResponseEvent; | |
| use crate::compact::InitialContextInjection; | |
| use crate::compact::run_inline_auto_compact_task; | |
| use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_remote_auto_compact_task_v2; | |
| use crate::connectors; | |
| use crate::context::ContextualUserFragment; | |
| use crate::context::UserVerificationNotice; | |
| use crate::environment_selection::TurnEnvironmentSnapshot; | |
| use crate::feedback_tags; | |
| use crate::hook_runtime::drain_async_hook_results; | |
| use crate::hook_runtime::inspect_pending_input; | |
| use crate::hook_runtime::record_additional_contexts; | |
| use crate::hook_runtime::record_pending_input; | |
| use crate::hook_runtime::run_legacy_after_agent_hook; | |
| use crate::hook_runtime::run_pending_session_start_hooks; | |
| use crate::hook_runtime::run_turn_stop_hooks; | |
| use crate::mcp_skill_dependencies::maybe_prompt_and_install_mcp_dependencies; | |
| use crate::mentions::build_connector_slug_counts; | |
| use crate::mentions::collect_explicit_app_ids; | |
| use crate::mentions::collect_explicit_plugin_mentions; | |
| use crate::mentions::collect_tool_mentions_from_messages; | |
| use crate::plugins::build_plugin_injections; | |
| use crate::responses_metadata::CodexResponsesMetadata; | |
| use crate::responses_metadata::CodexResponsesRequestKind; | |
| use crate::responses_retry::ResponsesStreamRequest; | |
| use crate::responses_retry::ResponsesStreamRetryState; | |
| use crate::responses_retry::handle_retryable_response_stream_error; | |
| use crate::session::PreviousTurnSettings; | |
| use crate::session::TurnInput; | |
| use crate::session::daemon_recovery::RecordedTurnInput; | |
| use crate::session::session::Session; | |
| use crate::session::step_context::StepContext; | |
| use crate::session::turn_context::TurnContext; | |
| use crate::skills::emit_explicit_skill_invocations; | |
| use crate::stream_events_utils::HandleOutputCtx; | |
| use crate::stream_events_utils::InFlightFuture; | |
| use crate::stream_events_utils::TurnItemContributorPolicy; | |
| use crate::stream_events_utils::finalize_non_tool_response_item; | |
| use crate::stream_events_utils::handle_non_tool_response_item; | |
| use crate::stream_events_utils::handle_output_item_done; | |
| use crate::stream_events_utils::last_assistant_message_from_item; | |
| use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context; | |
| use crate::stream_events_utils::raw_assistant_output_text_from_item; | |
| use crate::stream_events_utils::record_completed_response_item_with_finalized_facts; | |
| use crate::tasks::emit_compact_metric; | |
| use crate::tools::ToolRouter; | |
| use crate::tools::context::SharedTurnDiffTracker; | |
| use crate::tools::parallel::ToolCallRuntime; | |
| use crate::tools::registry::ToolArgumentDiffConsumer; | |
| use crate::tools::router::ToolSuggestCandidates; | |
| use crate::tools::router::ToolSuggestPresentation; | |
| use crate::tools::spec_plan::build_tool_router; | |
| use crate::tools::spec_plan::tool_suggest_enabled; | |
| use crate::turn_diff_tracker::TurnDiffTracker; | |
| use crate::turn_timing::record_turn_ttft_metric; | |
| use crate::util::error_or_panic; | |
| use codex_analytics::AppInvocation; | |
| use codex_analytics::CompactionPhase; | |
| use codex_analytics::CompactionReason; | |
| use codex_analytics::InvocationType; | |
| use codex_analytics::TurnResolvedConfigFact; | |
| use codex_analytics::build_track_events_context; | |
| use codex_async_utils::OrCancelExt; | |
| use codex_connectors::AppToolPolicyEvaluator; | |
| use codex_core_plugins::RecommendedPluginCandidatesInput; | |
| use codex_extension_api::ExtensionData; | |
| use codex_extension_api::TurnInputContext; | |
| use codex_extension_api::TurnInputEnvironment; | |
| use codex_features::Feature; | |
| use codex_file_system::FindUpErrorPolicy; | |
| use codex_file_system::find_nearest_ancestor_with_markers; | |
| use codex_login::CodexAuth; | |
| use codex_model_provider::RemoteCompactionSupport; | |
| use codex_protocol::ResponseItemId; | |
| use codex_protocol::config_types::AutoCompactTokenLimitScope; | |
| use codex_protocol::config_types::ModeKind; | |
| use codex_protocol::config_types::ServiceTier; | |
| use codex_protocol::error::CodexErr; | |
| use codex_protocol::error::CodexErrorDetails; | |
| use codex_protocol::error::Result as CodexResult; | |
| use codex_protocol::items::PlanItem; | |
| use codex_protocol::items::TurnItem; | |
| use codex_protocol::items::build_hook_prompt_message; | |
| use codex_protocol::models::BaseInstructions; | |
| use codex_protocol::models::ContentItem; | |
| use codex_protocol::models::MessagePhase; | |
| use codex_protocol::models::ResponseItem; | |
| use codex_protocol::openai_models::ModelInfo; | |
| use codex_protocol::openai_models::ModelMessages; | |
| use codex_protocol::protocol::AgentMessageContentDeltaEvent; | |
| use codex_protocol::protocol::AgentReasoningSectionBreakEvent; | |
| use codex_protocol::protocol::CodexErrorInfo; | |
| use codex_protocol::protocol::ErrorEvent; | |
| use codex_protocol::protocol::EventMsg; | |
| use codex_protocol::protocol::InternalSessionSource; | |
| use codex_protocol::protocol::PlanDeltaEvent; | |
| use codex_protocol::protocol::ReasoningContentDeltaEvent; | |
| use codex_protocol::protocol::ReasoningRawContentDeltaEvent; | |
| use codex_protocol::protocol::SafetyBufferingEvent; | |
| use codex_protocol::protocol::SessionSource; | |
| use codex_protocol::protocol::TurnDiffEvent; | |
| use codex_protocol::protocol::WarningEvent; | |
| use codex_protocol::user_input::UserInput; | |
| use codex_skills::ToolMentionKind; | |
| use codex_skills::app_id_from_path; | |
| use codex_skills::build_skill_name_counts; | |
| use codex_skills::collect_explicit_skill_mentions; | |
| use codex_skills::tool_kind_for_path; | |
| use codex_skills_extension::HostSkillPrompts; | |
| use codex_skills_extension::InjectedHostSkillPrompts; | |
| use codex_thread_store::PersistContext; | |
| use codex_tools::DiscoverableTool; | |
| use codex_tools::ToolName; | |
| use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; | |
| use codex_utils_path_uri::PathUri; | |
| use codex_utils_stream_parser::AssistantTextChunk; | |
| use codex_utils_stream_parser::AssistantTextStreamParser; | |
| use codex_utils_stream_parser::ProposedPlanSegment; | |
| use codex_utils_stream_parser::extract_proposed_plan_text; | |
| use codex_utils_stream_parser::strip_citations; | |
| use futures::prelude::*; | |
| use futures::stream::FuturesOrdered; | |
| use tokio_util::sync::CancellationToken; | |
| use tracing::Instrument; | |
| use tracing::error; | |
| use tracing::field; | |
| use tracing::info; | |
| use tracing::instrument; | |
| use tracing::trace; | |
| use tracing::trace_span; | |
| use tracing::warn; | |
| const POST_SAMPLING_TOKEN_ESTIMATE_TARGET: &str = "codex_core::post_sampling_token_estimate"; | |
| /// Explicit MCP startup requirements retained across restarts within one user turn. | |
| pub(crate) struct McpStartupRequirements { | |
| required_servers: Vec<String>, | |
| required_plugins: HashSet<String>, | |
| } | |
| /// Takes initial turn input and runs a loop where, at each sampling request, | |
| /// the model replies with either: | |
| /// | |
| /// - requested function calls | |
| /// - an assistant message | |
| /// | |
| /// While it is possible for the model to return multiple of these items in a | |
| /// single sampling request, in practice, we generally one item per sampling request: | |
| /// | |
| /// - If the model requests a function call, we execute it and send the output | |
| /// back to the model in the next sampling request. | |
| /// - If the model sends only an assistant message, we record it in the | |
| /// conversation history and consider the turn complete. | |
| /// | |
| pub(crate) async fn run_turn( | |
| sess: Arc<Session>, | |
| turn_context: Arc<TurnContext>, | |
| mut input: Vec<TurnInput>, | |
| mcp_startup_requirements: &mut McpStartupRequirements, | |
| prewarmed_client_session: Option<ModelClientSession>, | |
| cancellation_token: CancellationToken, | |
| ) -> CodexResult<Option<String>> { | |
| if crate::guardian::is_basic_session_source(&turn_context.session_source) { | |
| crate::guardian::check_pending_guardian_input(&sess, &turn_context).await?; | |
| } | |
| // Record results from hooks that finished after the previous turn before this turn's user prompt. | |
| drain_async_hook_results(&sess, &turn_context, /*before_user_prompt*/ true).await; | |
| let mut client_session = | |
| prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session()); | |
| // TODO(ccunningham): Pre-turn compaction runs before context updates and the | |
| // new user message are recorded. Estimate pending incoming items (context | |
| // diffs/full reinjection + user input) and trigger compaction preemptively | |
| // when they would push the thread over the compaction threshold. | |
| if let Err(err) = run_pre_sampling_compact( | |
| &sess, | |
| &turn_context, | |
| &mut client_session, | |
| &cancellation_token, | |
| ) | |
| .await | |
| { | |
| // Compaction runs before the new input is recorded, so preserve it on every failure. | |
| run_hooks_and_record_inputs( | |
| &sess, | |
| &turn_context, | |
| &turn_context.capture_current_model_info(), | |
| &input, | |
| PersistContext::Standard, | |
| ) | |
| .await; | |
| if matches!(err.details(), CodexErrorDetails::TurnAborted) { | |
| return Err(err); | |
| } | |
| if matches!(err.details(), CodexErrorDetails::ToolCollision(_)) { | |
| return Err(err); | |
| } | |
| let error = err.to_codex_protocol_error(); | |
| sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) | |
| .await; | |
| // Publish the failure only after prompt hooks finish, so clients cannot react to | |
| // an error by steering follow-up input into a turn still preserving its prompt. | |
| let message_prefix = match turn_context.provider.capabilities().remote_compaction { | |
| RemoteCompactionSupport::V2 => Some("Error running remote compact task".to_string()), | |
| RemoteCompactionSupport::Unsupported => None, | |
| }; | |
| sess.send_event( | |
| turn_context.as_ref(), | |
| EventMsg::Error(err.to_error_event(message_prefix)), | |
| ) | |
| .await; | |
| error!("Failed to run pre-sampling compact"); | |
| return Ok(None); | |
| } | |
| let user_input = turn_user_input(&input); | |
| let allow_plugin_mentions = | |
| !crate::guardian::is_basic_session_source(&turn_context.session_source); | |
| let McpStartupRequirements { | |
| required_servers, | |
| required_plugins, | |
| } = mcp_startup_requirements; | |
| if allow_plugin_mentions { | |
| required_plugins.extend(crate::plugins::collect_explicit_plugin_ids(&user_input)); | |
| } | |
| let (input_required_servers, mentioned_plugins) = | |
| match required_mcp_servers_for_input(&sess, turn_context.as_ref(), &user_input) | |
| .or_cancel(&cancellation_token) | |
| .await | |
| { | |
| Ok(requirements) => requirements, | |
| Err(err) => { | |
| run_hooks_and_record_inputs( | |
| &sess, | |
| &turn_context, | |
| &turn_context.capture_current_model_info(), | |
| &input, | |
| PersistContext::Standard, | |
| ) | |
| .await; | |
| return Err(err.into()); | |
| } | |
| }; | |
| required_servers.extend(input_required_servers); | |
| required_servers.sort_unstable(); | |
| required_servers.dedup(); | |
| // run_turn owns the step used to seed context and make the first sampling request. | |
| let first_step_context = match sess | |
| .capture_step_context_with_required_mcp_servers( | |
| Arc::clone(&turn_context), | |
| &cancellation_token, | |
| required_servers, | |
| required_plugins, | |
| ) | |
| .await | |
| { | |
| Ok(step_context) => step_context, | |
| Err(err) if matches!(err.details(), CodexErrorDetails::TurnAborted) => { | |
| run_hooks_and_record_inputs( | |
| &sess, | |
| &turn_context, | |
| &turn_context.capture_current_model_info(), | |
| &input, | |
| PersistContext::Standard, | |
| ) | |
| .await; | |
| return Err(err); | |
| } | |
| Err(err) => return Err(err), | |
| }; | |
| // Keep the exact model-visible state used by this turn and its inline compactions. | |
| let (world_state, display_roots) = tokio::join!( | |
| sess.record_context_updates_and_set_reference_context_item(first_step_context.as_ref()), | |
| async { | |
| if first_step_context | |
| .turn | |
| .config | |
| .features | |
| .enabled(Feature::CwdRelativeTurnDiffs) | |
| { | |
| first_step_context | |
| .environments | |
| .turn_environments() | |
| .map(|environment| { | |
| ( | |
| environment.selection().environment_id, | |
| environment.cwd().clone(), | |
| ) | |
| }) | |
| .collect() | |
| } else { | |
| turn_diff_display_roots(first_step_context.as_ref()).await | |
| } | |
| }, | |
| ); | |
| let mut world_state = world_state?; | |
| let Some((injection_items, explicitly_enabled_connectors)) = build_skills_and_plugins( | |
| &sess, | |
| first_step_context.as_ref(), | |
| &user_input, | |
| &mentioned_plugins, | |
| &cancellation_token, | |
| ) | |
| .await | |
| else { | |
| return Ok(None); | |
| }; | |
| if run_pending_session_start_hooks(&sess, &turn_context).await { | |
| return Ok(None); | |
| } | |
| if crate::guardian::is_basic_session_source(&turn_context.session_source) | |
| && let Err(error) = crate::guardian::finalize_guardian_input( | |
| &sess, | |
| &first_step_context, | |
| &mut input, | |
| codex_guardian_context::HistoryTruncation::Preserve, | |
| ) | |
| .await | |
| { | |
| // Token-budget compaction resets history, which can discard the evidence | |
| // referenced by a pending delta review. Leave budget failures unreusable. | |
| if !matches!(error.details(), CodexErrorDetails::ContextWindowExceeded) | |
| || turn_context.config.features.enabled(Feature::TokenBudget) | |
| { | |
| return Err(error); | |
| } | |
| // Incoming evidence can overflow even below the normal history | |
| // threshold. Keep it pending while compacting, then select once more. | |
| sess.services | |
| .thread_extension_data | |
| .insert(crate::guardian::ExhaustedReviewBudget::Compacting); | |
| run_auto_compact( | |
| &sess, | |
| Arc::clone(&first_step_context), | |
| /*fallback_step_context*/ None, | |
| &mut client_session, | |
| InitialContextInjection::DoNotInject, | |
| CompactionReason::ContextLimit, | |
| CompactionPhase::PreTurn, | |
| ) | |
| .await?; | |
| world_state = sess | |
| .record_context_updates_and_set_reference_context_item(first_step_context.as_ref()) | |
| .await?; | |
| crate::guardian::finalize_guardian_input( | |
| &sess, | |
| &first_step_context, | |
| &mut input, | |
| codex_guardian_context::HistoryTruncation::Allow, | |
| ) | |
| .await?; | |
| } | |
| let mut can_drain_pending_input = input.is_empty(); | |
| if run_hooks_and_record_inputs( | |
| &sess, | |
| &turn_context, | |
| &first_step_context.settings.model_info, | |
| &input, | |
| PersistContext::TurnStart, | |
| ) | |
| .await | |
| { | |
| return Ok(None); | |
| } | |
| // Only speculate after hooks accept the turn, using its finalized tools and permissions. | |
| { | |
| let mut state = sess.state.lock().await; | |
| if state.shell_snapshot_prewarm.is_none() { | |
| state.shell_snapshot_prewarm = | |
| sess.prewarm_shell_snapshots(first_step_context.as_ref()); | |
| } | |
| } | |
| sess.merge_connector_selection(explicitly_enabled_connectors.clone()) | |
| .await; | |
| sess.set_previous_turn_settings(Some(PreviousTurnSettings { | |
| model: turn_context.model_info().slug.clone(), | |
| comp_hash: turn_context.model_info().comp_hash.clone(), | |
| realtime_active: Some(turn_context.realtime_active), | |
| })) | |
| .await; | |
| for response_item in injection_items { | |
| sess.record_conversation_items( | |
| &turn_context, | |
| &first_step_context.settings.model_info, | |
| std::slice::from_ref(&response_item), | |
| ) | |
| .await; | |
| } | |
| track_turn_resolved_config_analytics(&sess, &turn_context, &input).await; | |
| let mut last_agent_message: Option<String> = None; | |
| let mut stop_hook_active = false; | |
| // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains | |
| // many turns, from the perspective of the user, it is a single turn. | |
| let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new( | |
| TurnDiffTracker::with_environment_display_roots(display_roots), | |
| )); | |
| // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse | |
| // one instance across retries within this turn. | |
| // Pending input is drained into history before building the next model request. | |
| // However, we defer that drain until after sampling in two cases: | |
| // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first. | |
| // 2. After auto-compact, when model/tool continuation needs to resume before any steer. | |
| let mut next_step_context = Some(first_step_context); | |
| let mut guardian_budget_compacted = false; | |
| loop { | |
| // Note that pending_input would be something like a message the user | |
| // submitted through the UI while the model was running. Though the UI | |
| // may support this, the model might not. | |
| let pending_input = if can_drain_pending_input { | |
| sess.input_queue | |
| .get_pending_input(&sess.active_turn) | |
| .await | |
| .0 | |
| } else { | |
| Vec::new() | |
| }; | |
| if run_hooks_and_record_inputs( | |
| &sess, | |
| &turn_context, | |
| &turn_context.capture_current_model_info(), | |
| &pending_input, | |
| PersistContext::SteeredUserInput, | |
| ) | |
| .await | |
| { | |
| break; | |
| } | |
| // Input and turn-start injections are recorded before recovery can continue this turn. | |
| turn_context.extension_data.insert(RecordedTurnInput); | |
| let window_id = sess.current_window_id().await; | |
| super::rollout_budget::maybe_record_reminder( | |
| sess.as_ref(), | |
| turn_context.as_ref(), | |
| &window_id, | |
| ) | |
| .await; | |
| // Capture once so context, advertised tools, and tool calls share one request view. | |
| let step_context = match next_step_context.take() { | |
| Some(step_context) if pending_input.is_empty() => step_context, | |
| None if pending_input.is_empty() => { | |
| sess.capture_step_context_with_required_mcp_servers( | |
| Arc::clone(&turn_context), | |
| &cancellation_token, | |
| required_servers, | |
| required_plugins, | |
| ) | |
| .await? | |
| } | |
| Some(_) | None => { | |
| let pending_user_input = turn_user_input(&pending_input); | |
| if allow_plugin_mentions { | |
| required_plugins.extend(crate::plugins::collect_explicit_plugin_ids( | |
| &pending_user_input, | |
| )); | |
| } | |
| let (pending_required_servers, _) = required_mcp_servers_for_input( | |
| &sess, | |
| turn_context.as_ref(), | |
| &pending_user_input, | |
| ) | |
| .or_cancel(&cancellation_token) | |
| .await?; | |
| required_servers.extend(pending_required_servers); | |
| required_servers.sort_unstable(); | |
| required_servers.dedup(); | |
| sess.capture_step_context_with_required_mcp_servers( | |
| Arc::clone(&turn_context), | |
| &cancellation_token, | |
| required_servers, | |
| required_plugins, | |
| ) | |
| .await? | |
| } | |
| }; | |
| let sampling_request_result: CodexResult<_> = async { | |
| super::time_reminder::maybe_record_current_time_reminder( | |
| sess.as_ref(), | |
| turn_context.as_ref(), | |
| &window_id, | |
| ) | |
| .await?; | |
| world_state = sess | |
| .record_step_world_state_if_changed(&world_state, step_context.as_ref()) | |
| .await?; | |
| // Keep the override after accepted input so history truncation removes them together. | |
| sess.record_reasoning_effort_override(step_context.as_ref()) | |
| .await; | |
| // Construct the input that we will send to the model. | |
| let sampling_request_input: Vec<ResponseItem> = async { | |
| sess.clone_history() | |
| .await | |
| .for_prompt(&step_context.settings.model_info.input_modalities) | |
| } | |
| .instrument(trace_span!("run_turn.prepare_sampling_request_input")) | |
| .await; | |
| let responses_metadata = sess | |
| .responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Turn) | |
| .await; | |
| run_sampling_request( | |
| Arc::clone(&sess), | |
| Arc::clone(&step_context), | |
| Arc::clone(&turn_context.extension_data), | |
| Arc::clone(&turn_diff_tracker), | |
| &mut client_session, | |
| &responses_metadata, | |
| sampling_request_input, | |
| cancellation_token.child_token(), | |
| ) | |
| .await | |
| } | |
| .await; | |
| match sampling_request_result { | |
| Ok((sampling_request_output, sampling_request_input)) => { | |
| guardian_budget_compacted = false; | |
| let SamplingRequestResult { | |
| needs_follow_up: model_needs_follow_up, | |
| last_agent_message: sampling_request_last_agent_message, | |
| } = sampling_request_output; | |
| if model_needs_follow_up { | |
| sess.input_queue | |
| .accept_mailbox_delivery_for_current_turn( | |
| &sess.active_turn, | |
| &turn_context.sub_id, | |
| ) | |
| .await; | |
| } | |
| can_drain_pending_input = true; | |
| // Process async hooks only after sampling and its tools have finished. | |
| drain_async_hook_results(&sess, &turn_context, /*before_user_prompt*/ false).await; | |
| let (has_pending_input, token_status) = async { | |
| let has_pending_input = | |
| sess.input_queue.has_pending_input(&sess.active_turn).await; | |
| let token_status = super::context_window::context_window_token_status( | |
| sess.as_ref(), | |
| turn_context.as_ref(), | |
| ) | |
| .await; | |
| (has_pending_input, token_status) | |
| } | |
| .instrument(trace_span!("run_turn.collect_post_sampling_state")) | |
| .await; | |
| let needs_follow_up = model_needs_follow_up || has_pending_input; | |
| let token_limit_reached = token_status.token_limit_reached; | |
| trace!( | |
| turn_id = %turn_context.sub_id, | |
| total_usage_tokens = token_status.active_context_tokens, | |
| auto_compact_scope_tokens = token_status.auto_compact_scope_tokens, | |
| auto_compact_scope_limit = ?token_status.auto_compact_scope_limit, | |
| auto_compact_limit_scope = ?turn_context.config.model_auto_compact_token_limit_scope, | |
| auto_compact_window_prefill_tokens = ?token_status.auto_compact_window_prefill_tokens, | |
| full_context_window_limit = ?token_status.full_context_window_limit, | |
| full_context_window_limit_reached = token_status.full_context_window_limit_reached, | |
| token_limit_reached, | |
| model_needs_follow_up, | |
| has_pending_input, | |
| needs_follow_up, | |
| "post sampling token usage" | |
| ); | |
| if tracing::event_enabled!( | |
| target: POST_SAMPLING_TOKEN_ESTIMATE_TARGET, | |
| tracing::Level::TRACE, | |
| turn_id, | |
| estimated_token_count, | |
| message | |
| ) { | |
| let estimated_token_count = | |
| sess.get_estimated_token_count(turn_context.as_ref()).await; | |
| trace!( | |
| target: POST_SAMPLING_TOKEN_ESTIMATE_TARGET, | |
| turn_id = %turn_context.sub_id, | |
| estimated_token_count = ?estimated_token_count, | |
| "post sampling token estimate" | |
| ); | |
| } | |
| let should_roll_over = needs_follow_up | |
| && (sess.take_new_context_window_request().await || token_limit_reached); | |
| let allow_auto_compact_fallback = !should_roll_over && !token_limit_reached; | |
| super::token_budget::maybe_record( | |
| sess.as_ref(), | |
| turn_context.as_ref(), | |
| token_status.base_window_tokens_remaining, | |
| allow_auto_compact_fallback, | |
| ) | |
| .await; | |
| // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. | |
| if should_roll_over { | |
| if let Err(err) = run_auto_compact( | |
| &sess, | |
| Arc::clone(&step_context), | |
| /*fallback_step_context*/ None, | |
| &mut client_session, | |
| InitialContextInjection::BeforeLastUserMessage { | |
| world_state: Arc::clone(&world_state), | |
| step_context: Arc::clone(&step_context), | |
| }, | |
| CompactionReason::ContextLimit, | |
| CompactionPhase::MidTurn, | |
| ) | |
| .await | |
| { | |
| if matches!(err.details(), CodexErrorDetails::TurnAborted) { | |
| return Err(err); | |
| } | |
| let error = err.to_codex_protocol_error(); | |
| sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) | |
| .await; | |
| return Ok(None); | |
| } | |
| if run_pending_session_start_hooks(&sess, &turn_context).await { | |
| return Ok(None); | |
| } | |
| can_drain_pending_input = !model_needs_follow_up; | |
| continue; | |
| } | |
| if !needs_follow_up { | |
| last_agent_message = sampling_request_last_agent_message; | |
| let stop_outcome = run_turn_stop_hooks( | |
| &sess, | |
| &step_context, | |
| stop_hook_active, | |
| last_agent_message.clone(), | |
| ) | |
| .await; | |
| if matches!( | |
| turn_context.session_source, | |
| SessionSource::Internal(InternalSessionSource::MemoryConsolidation) | |
| ) && (stop_outcome.should_block || stop_outcome.should_stop) | |
| { | |
| // Do not feed managed rejections back into an unattended memory loop. | |
| return Err(CodexErr::InvalidRequest( | |
| "Memory consolidation was rejected by a Stop hook.".to_string(), | |
| )); | |
| } | |
| if stop_outcome.should_block { | |
| if let Some(hook_prompt_message) = | |
| build_hook_prompt_message(&stop_outcome.continuation_fragments) | |
| { | |
| sess.record_response_item_and_emit_turn_item( | |
| &turn_context, | |
| &step_context.settings.model_info, | |
| hook_prompt_message, | |
| ) | |
| .await; | |
| sess.input_queue | |
| .accept_mailbox_delivery_for_current_turn( | |
| &sess.active_turn, | |
| &turn_context.sub_id, | |
| ) | |
| .await; | |
| stop_hook_active = true; | |
| continue; | |
| } else { | |
| sess.send_event( | |
| &turn_context, | |
| EventMsg::Warning(WarningEvent { | |
| message: "Stop hook requested continuation without a prompt; ignoring the block.".to_string(), | |
| }), | |
| ) | |
| .await; | |
| } | |
| } | |
| if stop_outcome.should_stop { | |
| break; | |
| } | |
| if run_legacy_after_agent_hook( | |
| &sess, | |
| &turn_context, | |
| &sampling_request_input, | |
| last_agent_message.clone(), | |
| ) | |
| .await | |
| { | |
| return Ok(None); | |
| } | |
| break; | |
| } | |
| continue; | |
| } | |
| Err(err) | |
| if matches!(err.details(), CodexErrorDetails::ContextWindowExceeded) | |
| && !guardian_budget_compacted | |
| && !turn_context.config.features.enabled(Feature::TokenBudget) | |
| && sess | |
| .services | |
| .thread_extension_data | |
| .get::<crate::guardian::ExhaustedReviewBudget>() | |
| .is_some() => | |
| { | |
| // Tool continuations can also cross the complete-request limit. | |
| // Only summarizing compaction preserves the action and evidence; | |
| // token-budget resets must fail closed and retire the reviewer. | |
| // Retry once per model step, so ineffective compaction cannot loop. | |
| guardian_budget_compacted = true; | |
| sess.services | |
| .thread_extension_data | |
| .insert(crate::guardian::ExhaustedReviewBudget::Compacting); | |
| run_auto_compact( | |
| &sess, | |
| Arc::clone(&step_context), | |
| /*fallback_step_context*/ None, | |
| &mut client_session, | |
| InitialContextInjection::BeforeLastUserMessage { | |
| world_state: Arc::clone(&world_state), | |
| step_context: Arc::clone(&step_context), | |
| }, | |
| CompactionReason::ContextLimit, | |
| CompactionPhase::MidTurn, | |
| ) | |
| .await?; | |
| can_drain_pending_input = false; | |
| continue; | |
| } | |
| Err(err) if matches!(err.details(), CodexErrorDetails::TurnAborted) => { | |
| return Err(err); | |
| } | |
| Err(codex_error) | |
| if matches!( | |
| codex_error.details(), | |
| CodexErrorDetails::InvalidImageRequest() | |
| ) => | |
| { | |
| sess.track_turn_codex_error(turn_context.as_ref(), &codex_error); | |
| let error = CodexErrorInfo::BadRequest; | |
| sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) | |
| .await; | |
| let event = EventMsg::Error(ErrorEvent { | |
| misalignment: None, | |
| message: "Invalid image in your last message. Please remove it and try again." | |
| .to_string(), | |
| codex_error_info: Some(error), | |
| }); | |
| sess.send_event(&turn_context, event).await; | |
| break; | |
| } | |
| Err(e) => { | |
| info!("Turn error: {e:#}"); | |
| if matches!( | |
| e.details(), | |
| CodexErrorDetails::MisalignmentPolicyViolation { .. } | |
| ) { | |
| sess.conversation.retire_handoffs_for_misalignment().await; | |
| } | |
| let error = e.to_codex_protocol_error(); | |
| sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) | |
| .await; | |
| sess.track_turn_codex_error(turn_context.as_ref(), &e); | |
| let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); | |
| sess.send_event(&turn_context, event).await; | |
| // let the user continue the conversation | |
| break; | |
| } | |
| } | |
| } | |
| Ok(last_agent_message) | |
| } | |
| async fn turn_diff_display_roots(step_context: &StepContext) -> Vec<(String, PathUri)> { | |
| let mut display_roots = Vec::new(); | |
| for turn_environment in step_context.environments.turn_environments() { | |
| let cwd = turn_environment.cwd(); | |
| // A turn cwd is expected to be a directory. If it is a file, the failed `<cwd>/.git` probe | |
| // is ignored and ancestor search continues from its parent. | |
| let root = find_nearest_ancestor_with_markers( | |
| turn_environment.environment.get_filesystem().as_ref(), | |
| cwd, | |
| vec![".git".to_string()], | |
| FindUpErrorPolicy::Ignore, | |
| /*sandbox*/ None, | |
| ) | |
| .await | |
| .ok() | |
| .flatten() | |
| .unwrap_or_else(|| cwd.clone()); | |
| display_roots.push((turn_environment.selection.environment_id.clone(), root)); | |
| } | |
| display_roots | |
| } | |
| pub(crate) async fn run_hooks_and_record_inputs( | |
| sess: &Arc<Session>, | |
| turn_context: &Arc<TurnContext>, | |
| model_info: &ModelInfo, | |
| input: &[TurnInput], | |
| persist_context: PersistContext, | |
| ) -> bool { | |
| // Cancellation can reach this path before Guardian's tools and context are | |
| // resolved. Only finalized evidence may enter reusable reviewer history. | |
| if sess | |
| .services | |
| .thread_extension_data | |
| .get::<crate::guardian::PendingReviewContext>() | |
| .is_some() | |
| { | |
| return false; | |
| } | |
| let mut blocked_input = false; | |
| let mut accepted_user_input = false; | |
| for input_item in input { | |
| let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await; | |
| if hook_outcome.should_stop { | |
| blocked_input = true; | |
| record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; | |
| } else { | |
| if matches!(input_item, TurnInput::UserInput { content, .. } if !content.is_empty()) { | |
| accepted_user_input = true; | |
| } | |
| // Tool outputs retain their durability barrier, including in mixed input batches. | |
| let input_persist_context = if persist_context == PersistContext::SteeredUserInput | |
| && matches!(input_item, TurnInput::FunctionCallOutput(_)) | |
| { | |
| PersistContext::Standard | |
| } else { | |
| persist_context | |
| }; | |
| record_pending_input( | |
| sess, | |
| turn_context, | |
| model_info, | |
| input_item.clone(), | |
| hook_outcome.additional_contexts, | |
| input_persist_context, | |
| ) | |
| .await; | |
| } | |
| } | |
| blocked_input && !accepted_user_input | |
| } | |
| fn turn_user_input(input: &[TurnInput]) -> Vec<UserInput> { | |
| input | |
| .iter() | |
| .filter_map(|item| match item { | |
| TurnInput::UserInput { content, .. } => Some(content.as_slice()), | |
| TurnInput::ResponseItem(_) | |
| | TurnInput::FunctionCallOutput(_) | |
| | TurnInput::InterAgentCommunication(_) => None, | |
| }) | |
| .flatten() | |
| .cloned() | |
| .collect() | |
| } | |
| async fn required_mcp_servers_for_input( | |
| sess: &Arc<Session>, | |
| turn_context: &TurnContext, | |
| user_input: &[UserInput], | |
| ) -> (Vec<String>, Vec<crate::plugins::PluginCapabilitySummary>) { | |
| if crate::guardian::is_basic_session_source(&turn_context.session_source) { | |
| return (Vec::new(), Vec::new()); | |
| } | |
| // Plugin capabilities depend on authentication, so project them only after | |
| // the runtime has aligned the plugin manager with its current account. | |
| sess.refresh_mcp_if_dirty().await; | |
| let loaded_plugins = sess | |
| .services | |
| .plugins_manager | |
| .plugins_for_config(&turn_context.config.plugins_config_input()) | |
| .await | |
| .without_plugins(&turn_context.disabled_plugin_ids); | |
| let current_config = sess.services.mcp_runtime.current_config(); | |
| let mentioned_plugins = | |
| collect_explicit_plugin_mentions(user_input, loaded_plugins.capability_summaries()); | |
| let mut required_servers = mentioned_plugins | |
| .iter() | |
| .flat_map(|plugin| plugin.mcp_server_names.iter().cloned()) | |
| .collect::<HashSet<_>>(); | |
| let messages = user_input | |
| .iter() | |
| .filter_map(|input| match input { | |
| UserInput::Text { text, .. } => Some(text.clone()), | |
| _ => None, | |
| }) | |
| .collect::<Vec<_>>(); | |
| let mentions = collect_tool_mentions_from_messages(&messages); | |
| let paths = user_input | |
| .iter() | |
| .filter_map(|input| match input { | |
| UserInput::Mention { path, .. } => Some(path.clone()), | |
| _ => None, | |
| }) | |
| .chain(mentions.paths); | |
| required_servers.extend(paths.filter_map(|path| { | |
| path.strip_prefix("mcp://") | |
| .filter(|server| !server.is_empty()) | |
| .map(str::to_string) | |
| })); | |
| let connector_slug_counts = if turn_context.apps_enabled() && !mentions.plain_names.is_empty() { | |
| let cached_connectors = | |
| connectors::list_cached_accessible_connectors_from_mcp_tools(&turn_context.config) | |
| .await; | |
| let accessible_connectors = match cached_connectors { | |
| Some(connectors) => connectors, | |
| None => sess | |
| .services | |
| .mcp_runtime | |
| .current_binding() | |
| .await | |
| .map(|binding| connectors::accessible_connectors_from_mcp_tools(binding.tools())) | |
| .unwrap_or_default(), | |
| }; | |
| let connector_ids = current_config | |
| .iter() | |
| .flat_map(|config| config.connector_snapshot.connector_ids()) | |
| .map(|connector_id| connector_id.0.clone()); | |
| build_connector_slug_counts( | |
| &codex_connectors::merge::merge_plugin_connectors_with_accessible( | |
| connector_ids, | |
| accessible_connectors, | |
| ), | |
| ) | |
| } else { | |
| HashMap::new() | |
| }; | |
| let skills_snapshot = turn_context.skills_snapshot(); | |
| let skills_outcome = skills_snapshot.outcome(); | |
| let mentioned_skills = | |
| collect_explicit_skill_mentions(user_input, skills_outcome, &connector_slug_counts); | |
| for skill in mentioned_skills { | |
| if let Some(dependencies) = skill.dependencies { | |
| required_servers.extend( | |
| dependencies | |
| .tools | |
| .into_iter() | |
| .filter(|tool| tool.r#type.eq_ignore_ascii_case("mcp")) | |
| .map(|tool| tool.value), | |
| ); | |
| } | |
| if let Some(plugin_id) = skill.plugin_id.as_deref() | |
| && let Some(plugin) = loaded_plugins | |
| .capability_summaries() | |
| .iter() | |
| .find(|plugin| plugin.config_name == plugin_id) | |
| { | |
| required_servers.extend(plugin.mcp_server_names.iter().cloned()); | |
| } | |
| } | |
| (required_servers.into_iter().collect(), mentioned_plugins) | |
| } | |
| async fn build_skills_and_plugins( | |
| sess: &Arc<Session>, | |
| step_context: &StepContext, | |
| user_input: &[UserInput], | |
| mentioned_plugins: &[crate::plugins::PluginCapabilitySummary], | |
| cancellation_token: &CancellationToken, | |
| ) -> Option<(Vec<ResponseItem>, HashSet<String>)> { | |
| let turn_context = step_context.turn.as_ref(); | |
| // Guardian input embeds the parent transcript as untrusted evidence. Do not interpret skill or | |
| // plugin mentions from that generated prompt as requests to inject additional instructions. | |
| if crate::guardian::is_basic_session_source(&turn_context.session_source) { | |
| return Some((Vec::new(), HashSet::new())); | |
| } | |
| let tracking = build_track_events_context( | |
| turn_context.model_info().slug.clone(), | |
| sess.thread_id.to_string(), | |
| turn_context.sub_id.clone(), | |
| turn_context.originator.clone(), | |
| ); | |
| let connector_snapshot = step_context.mcp.config().connector_snapshot.clone(); | |
| let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { | |
| // Plugin mentions need raw MCP/app inventory even when app tools | |
| // are normally hidden so we can describe the plugin's currently | |
| // usable capabilities for this turn. | |
| step_context.mcp.tools() | |
| } else { | |
| &[] | |
| }; | |
| let available_connectors = if turn_context.apps_enabled() { | |
| let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( | |
| connector_snapshot | |
| .connector_ids() | |
| .iter() | |
| .map(|connector_id| connector_id.0.clone()), | |
| connectors::accessible_connectors_from_mcp_tools(mcp_tools), | |
| ); | |
| AppToolPolicyEvaluator::new(&turn_context.config.config_layer_stack) | |
| .apply_app_enabled_state(connectors) | |
| } else { | |
| Vec::new() | |
| }; | |
| let skills_snapshot = turn_context.skills_snapshot(); | |
| let skills_outcome = skills_snapshot.outcome(); | |
| let connector_slug_counts = build_connector_slug_counts(&available_connectors); | |
| let extension_injection_items = | |
| build_extension_turn_input_items(sess, step_context, user_input, cancellation_token) | |
| .await?; | |
| let skill_name_counts_lower = | |
| build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; | |
| let mentioned_skills = | |
| collect_explicit_skill_mentions(user_input, skills_outcome, &connector_slug_counts); | |
| maybe_prompt_and_install_mcp_dependencies( | |
| sess, | |
| turn_context, | |
| cancellation_token, | |
| &mentioned_skills, | |
| Some(sess.mcp_elicitation_reviewer()), | |
| ) | |
| .await; | |
| let injected_host_skill_prompts = turn_context | |
| .extension_data | |
| .get::<InjectedHostSkillPrompts>(); | |
| let HostSkillPrompts { | |
| fragments, | |
| injected: injected_host_skills, | |
| warnings: host_skill_warnings, | |
| } = skills_snapshot.load_skill_prompts(&mentioned_skills).await; | |
| emit_explicit_skill_invocations( | |
| sess, | |
| turn_context, | |
| &mentioned_skills, | |
| &injected_host_skills, | |
| tracking.clone(), | |
| ) | |
| .await; | |
| for message in host_skill_warnings { | |
| sess.send_event(turn_context, EventMsg::Warning(WarningEvent { message })) | |
| .await; | |
| } | |
| let skill_items = fragments | |
| .into_iter() | |
| .map(ContextualUserFragment::into_boxed_response_item) | |
| .collect::<Vec<_>>(); | |
| let skill_connector_ids = collect_explicit_app_ids_from_skill_items( | |
| &skill_items, | |
| &available_connectors, | |
| &skill_name_counts_lower, | |
| ); | |
| let plugin_items = build_plugin_injections(mentioned_plugins, mcp_tools, &available_connectors); | |
| let mut explicitly_enabled_connectors = collect_explicit_app_ids(user_input); | |
| explicitly_enabled_connectors.extend(skill_connector_ids); | |
| let connector_names_by_id = available_connectors | |
| .iter() | |
| .map(|connector| (connector.id.as_str(), connector.name.as_str())) | |
| .collect::<HashMap<&str, &str>>(); | |
| let mentioned_app_invocations = explicitly_enabled_connectors | |
| .iter() | |
| .map(|connector_id| AppInvocation { | |
| connector_id: Some(connector_id.clone()), | |
| app_name: connector_names_by_id | |
| .get(connector_id.as_str()) | |
| .map(|name| (*name).to_string()), | |
| invocation_type: Some(InvocationType::Explicit), | |
| }) | |
| .collect::<Vec<_>>(); | |
| sess.services | |
| .analytics_events_client | |
| .track_app_mentioned(tracking.clone(), mentioned_app_invocations); | |
| for summary in mentioned_plugins { | |
| if let Some(plugin) = sess | |
| .services | |
| .plugins_manager | |
| .telemetry_metadata_for_capability_summary(summary) | |
| { | |
| sess.services | |
| .analytics_events_client | |
| .track_plugin_used(tracking.clone(), plugin); | |
| } | |
| } | |
| let mut injection_items = match injected_host_skill_prompts { | |
| Some(injected_host_skill_prompts) => skill_items | |
| .into_iter() | |
| .zip(injected_host_skills.iter()) | |
| .filter_map(|(item, skill)| { | |
| (!injected_host_skill_prompts | |
| .contains_path(&skill.path_to_skills_md.to_string_lossy())) | |
| .then_some(item) | |
| }) | |
| .collect(), | |
| None => skill_items, | |
| }; | |
| injection_items.extend(plugin_items); | |
| injection_items.extend(extension_injection_items); | |
| Some((injection_items, explicitly_enabled_connectors)) | |
| } | |
| async fn build_extension_turn_input_items( | |
| sess: &Arc<Session>, | |
| step_context: &StepContext, | |
| user_input: &[UserInput], | |
| cancellation_token: &CancellationToken, | |
| ) -> Option<Vec<ResponseItem>> { | |
| let turn_context = step_context.turn.as_ref(); | |
| let contributors = sess.services.extensions.turn_input_contributors().to_vec(); | |
| if contributors.is_empty() { | |
| return Some(Vec::new()); | |
| } | |
| let environments = step_context | |
| .environments | |
| .turn_environments() | |
| .enumerate() | |
| .map(|(index, environment)| TurnInputEnvironment { | |
| _lifetime: PhantomData, | |
| environment_id: environment.selection.environment_id.clone(), | |
| cwd: environment.cwd().clone(), | |
| is_primary: index == 0, | |
| }) | |
| .collect::<Vec<_>>(); | |
| let input = TurnInputContext { | |
| turn_id: turn_context.sub_id.to_string(), | |
| user_input: user_input.to_vec(), | |
| environments, | |
| }; | |
| let extension_metrics = | |
| super::extension_metrics::from_session_telemetry(turn_context.session_telemetry.clone()); | |
| let mut items = Vec::new(); | |
| for contributor in contributors { | |
| let contributed_fragments = contributor | |
| .contribute( | |
| input.clone(), | |
| Some(Arc::clone(&extension_metrics)), | |
| &sess.services.session_extension_data, | |
| &sess.services.thread_extension_data, | |
| turn_context.extension_data.as_ref(), | |
| ) | |
| .or_cancel(cancellation_token) | |
| .await | |
| .ok()?; | |
| items.extend( | |
| contributed_fragments | |
| .into_iter() | |
| .map(ContextualUserFragment::into_boxed_response_item), | |
| ); | |
| } | |
| Some(items) | |
| } | |
| async fn track_turn_resolved_config_analytics( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| input: &[TurnInput], | |
| ) { | |
| let thread_config = sess.thread_config_snapshot().await; | |
| let is_first_turn = { | |
| let mut state = sess.state.lock().await; | |
| state.take_next_turn_is_first() | |
| }; | |
| sess.services | |
| .analytics_events_client | |
| .track_turn_resolved_config(TurnResolvedConfigFact { | |
| turn_id: turn_context.sub_id.clone(), | |
| thread_id: sess.thread_id.to_string(), | |
| turn_metadata: turn_context.turn_metadata_state.clone(), | |
| num_input_images: input | |
| .iter() | |
| .filter_map(|item| match item { | |
| TurnInput::UserInput { content, .. } => Some(content.as_slice()), | |
| TurnInput::ResponseItem(_) | |
| | TurnInput::FunctionCallOutput(_) | |
| | TurnInput::InterAgentCommunication(_) => None, | |
| }) | |
| .flatten() | |
| .filter(|item| { | |
| matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) | |
| }) | |
| .count(), | |
| submission_type: None, | |
| ephemeral: thread_config.ephemeral, | |
| session_source: thread_config.session_source, | |
| model: turn_context.model_info().slug.clone(), | |
| model_provider: turn_context.config.model_provider_id.clone(), | |
| permission_profile: turn_context.permission_profile(), | |
| permission_profile_cwd: turn_context.cwd.to_path_buf(), | |
| reasoning_effort: turn_context.reasoning_effort().cloned(), | |
| reasoning_summary: Some(turn_context.reasoning_summary()), | |
| service_tier: turn_context | |
| .config | |
| .service_tier | |
| .as_deref() | |
| .and_then(ServiceTier::from_request_value), | |
| approval_policy: turn_context.approval_policy(), | |
| approvals_reviewer: turn_context.config.approvals_reviewer, | |
| guardian_v2_enabled: sess | |
| .services | |
| .thread_extension_data | |
| .get::<codex_extension_api::GuardianV2Enabled>() | |
| .is_some(), | |
| sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), | |
| collaboration_mode: turn_context.mode(), | |
| personality: turn_context.personality(), | |
| workspace_kind: turn_context.turn_metadata_state.workspace_kind(), | |
| is_first_turn, | |
| }); | |
| } | |
| async fn run_pre_sampling_compact( | |
| sess: &Arc<Session>, | |
| turn_context: &Arc<TurnContext>, | |
| client_session: &mut ModelClientSession, | |
| cancellation_token: &CancellationToken, | |
| ) -> CodexResult<()> { | |
| maybe_run_previous_model_inline_compact(sess, turn_context, client_session, cancellation_token) | |
| .await?; | |
| let token_status = | |
| super::context_window::context_window_token_status(sess.as_ref(), turn_context.as_ref()) | |
| .await; | |
| // Compact if the configured auto-compaction budget or usable context window is exhausted. | |
| if token_status.token_limit_reached { | |
| // Pre-turn compaction runs before run_turn creates the normal sampling step. | |
| let step_context = sess | |
| .capture_step_context(Arc::clone(turn_context), cancellation_token) | |
| .await?; | |
| run_auto_compact( | |
| sess, | |
| step_context, | |
| /*fallback_step_context*/ None, | |
| client_session, | |
| InitialContextInjection::DoNotInject, | |
| CompactionReason::ContextLimit, | |
| CompactionPhase::PreTurn, | |
| ) | |
| .await?; | |
| } | |
| Ok(()) | |
| } | |
| /// Returns true only when both turns declare compaction compatibility hashes and they differ. | |
| /// A missing hash does not provide enough information to trigger compaction. | |
| fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { | |
| previous | |
| .zip(current) | |
| .is_some_and(|(previous, current)| previous != current) | |
| } | |
| /// Captures the current model's request-scoped state for retrying previous-model compaction. | |
| /// | |
| /// Returns `None` when the active authentication does not use the Codex backend, the provider is | |
| /// not OpenAI, or the previous and current model are the same. | |
| async fn capture_current_model_fallback_step_context( | |
| sess: &Arc<Session>, | |
| turn_context: &Arc<TurnContext>, | |
| previous_model: &str, | |
| cancellation_token: &CancellationToken, | |
| ) -> CodexResult<Option<Arc<StepContext>>> { | |
| let uses_codex_backend = turn_context | |
| .auth_manager | |
| .as_deref() | |
| .is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend); | |
| if !uses_codex_backend | |
| || !turn_context.provider.info().is_openai() | |
| || previous_model == turn_context.model_info().slug | |
| { | |
| return Ok(None); | |
| } | |
| sess.capture_speculative_step_context(Arc::clone(turn_context), cancellation_token) | |
| .await | |
| .map(Some) | |
| } | |
| /// Runs pre-sampling compaction against the previous model when its compaction compatibility | |
| /// hash changed or when switching to a smaller context-window model. | |
| /// | |
| /// Returns `Err(_)` only when compaction was attempted and failed. | |
| async fn maybe_run_previous_model_inline_compact( | |
| sess: &Arc<Session>, | |
| turn_context: &Arc<TurnContext>, | |
| client_session: &mut ModelClientSession, | |
| cancellation_token: &CancellationToken, | |
| ) -> CodexResult<()> { | |
| let Some(previous_turn_settings) = sess.previous_turn_settings().await else { | |
| return Ok(()); | |
| }; | |
| let should_compact_for_comp_hash_change = comp_hash_changed( | |
| previous_turn_settings.comp_hash.as_deref(), | |
| turn_context.model_info().comp_hash.as_deref(), | |
| ); | |
| let previous_model = previous_turn_settings.model; | |
| let previous_model_turn_context = Arc::new( | |
| turn_context | |
| .with_model(previous_model.clone(), &sess.services.models_manager) | |
| .await, | |
| ); | |
| if should_compact_for_comp_hash_change { | |
| let step_context = sess | |
| .capture_step_context(Arc::clone(&previous_model_turn_context), cancellation_token) | |
| .await?; | |
| let fallback_step_context = capture_current_model_fallback_step_context( | |
| sess, | |
| turn_context, | |
| previous_model.as_str(), | |
| cancellation_token, | |
| ) | |
| .await?; | |
| run_auto_compact( | |
| sess, | |
| step_context, | |
| fallback_step_context, | |
| client_session, | |
| InitialContextInjection::DoNotInject, | |
| CompactionReason::CompHashChanged, | |
| CompactionPhase::PreTurn, | |
| ) | |
| .await?; | |
| return Ok(()); | |
| } | |
| let Some(old_context_window) = previous_model_turn_context.model_context_window() else { | |
| return Ok(()); | |
| }; | |
| let Some(new_context_window) = turn_context.model_context_window() else { | |
| return Ok(()); | |
| }; | |
| let active_context_tokens = sess.get_total_token_usage().await; | |
| let previous_model_limit_reached = match turn_context | |
| .config | |
| .model_auto_compact_token_limit_scope | |
| { | |
| AutoCompactTokenLimitScope::Total => { | |
| let new_auto_compact_limit = turn_context | |
| .model_info() | |
| .auto_compact_token_limit() | |
| .unwrap_or(i64::MAX); | |
| active_context_tokens > new_auto_compact_limit | |
| || active_context_tokens >= new_context_window | |
| } | |
| AutoCompactTokenLimitScope::BodyAfterPrefix => active_context_tokens >= new_context_window, | |
| }; | |
| let should_run = previous_model_limit_reached | |
| && previous_model_turn_context.model_info().slug != turn_context.model_info().slug | |
| && old_context_window > new_context_window; | |
| if should_run { | |
| let step_context = sess | |
| .capture_step_context(Arc::clone(&previous_model_turn_context), cancellation_token) | |
| .await?; | |
| let fallback_step_context = capture_current_model_fallback_step_context( | |
| sess, | |
| turn_context, | |
| previous_model.as_str(), | |
| cancellation_token, | |
| ) | |
| .await?; | |
| run_auto_compact( | |
| sess, | |
| step_context, | |
| fallback_step_context, | |
| client_session, | |
| InitialContextInjection::DoNotInject, | |
| CompactionReason::ModelDownshift, | |
| CompactionPhase::PreTurn, | |
| ) | |
| .await?; | |
| } | |
| Ok(()) | |
| } | |
| async fn run_auto_compact( | |
| sess: &Arc<Session>, | |
| step_context: Arc<StepContext>, | |
| fallback_step_context: Option<Arc<StepContext>>, | |
| client_session: &mut ModelClientSession, | |
| initial_context_injection: InitialContextInjection, | |
| reason: CompactionReason, | |
| phase: CompactionPhase, | |
| ) -> CodexResult<()> { | |
| let turn_context = &step_context.turn; | |
| let _profile_guard = turn_context.turn_timing_state.begin_compaction(); | |
| if turn_context.config.features.enabled(Feature::TokenBudget) { | |
| // Compaction is the reset request, so force a new context window | |
| // instead of consuming a pending `new_context` tool request. | |
| crate::compact_token_budget::run_inline_auto_compact_task( | |
| Arc::clone(sess), | |
| step_context, | |
| initial_context_injection, | |
| ) | |
| .await?; | |
| return Ok(()); | |
| } | |
| match turn_context.provider.capabilities().remote_compaction { | |
| RemoteCompactionSupport::V2 => { | |
| emit_compact_metric( | |
| &sess.services.session_telemetry, | |
| "remote_v2", | |
| /*manual*/ false, | |
| ); | |
| run_inline_remote_auto_compact_task_v2( | |
| Arc::clone(sess), | |
| step_context, | |
| fallback_step_context, | |
| client_session, | |
| initial_context_injection, | |
| reason, | |
| phase, | |
| ) | |
| .await?; | |
| } | |
| RemoteCompactionSupport::Unsupported => { | |
| emit_compact_metric( | |
| &sess.services.session_telemetry, | |
| "local", | |
| /*manual*/ false, | |
| ); | |
| run_inline_auto_compact_task( | |
| Arc::clone(sess), | |
| Arc::clone(turn_context), | |
| initial_context_injection, | |
| reason, | |
| phase, | |
| ) | |
| .await?; | |
| } | |
| } | |
| Ok(()) | |
| } | |
| pub(super) fn collect_explicit_app_ids_from_skill_items( | |
| skill_items: &[ResponseItem], | |
| connectors: &[connectors::AppInfo], | |
| skill_name_counts_lower: &HashMap<String, usize>, | |
| ) -> HashSet<String> { | |
| if skill_items.is_empty() || connectors.is_empty() { | |
| return HashSet::new(); | |
| } | |
| let skill_messages = skill_items | |
| .iter() | |
| .filter_map(|item| match item { | |
| ResponseItem::Message { content, .. } => { | |
| content.iter().find_map(|content_item| match content_item { | |
| ContentItem::InputText { text } => Some(text.clone()), | |
| _ => None, | |
| }) | |
| } | |
| _ => None, | |
| }) | |
| .collect::<Vec<String>>(); | |
| if skill_messages.is_empty() { | |
| return HashSet::new(); | |
| } | |
| let mentions = collect_tool_mentions_from_messages(&skill_messages); | |
| let mention_names_lower = mentions | |
| .plain_names | |
| .iter() | |
| .map(|name| name.to_ascii_lowercase()) | |
| .collect::<HashSet<String>>(); | |
| let mut connector_ids = mentions | |
| .paths | |
| .iter() | |
| .filter(|path| tool_kind_for_path(path) == ToolMentionKind::App) | |
| .filter_map(|path| app_id_from_path(path).map(str::to_string)) | |
| .collect::<HashSet<String>>(); | |
| let connector_slug_counts = build_connector_slug_counts(connectors); | |
| for connector in connectors { | |
| let slug = codex_connectors::metadata::connector_mention_slug(connector); | |
| let connector_count = connector_slug_counts.get(&slug).copied().unwrap_or(0); | |
| let skill_count = skill_name_counts_lower.get(&slug).copied().unwrap_or(0); | |
| if connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&slug) { | |
| connector_ids.insert(connector.id.clone()); | |
| } | |
| } | |
| connector_ids | |
| } | |
| pub(crate) fn build_prompt( | |
| input: Vec<ResponseItem>, | |
| step_context: &StepContext, | |
| base_instructions: BaseInstructions, | |
| ) -> Prompt { | |
| let turn_context = &step_context.turn; | |
| Prompt { | |
| input, | |
| tools: step_context.tool_router.model_visible_specs(), | |
| parallel_tool_calls: true, | |
| base_instructions, | |
| output_schema: turn_context.final_output_json_schema.clone(), | |
| output_schema_strict: !crate::guardian::is_basic_session_source( | |
| &turn_context.session_source, | |
| ), | |
| cyber_access_program: turn_context.cyber_access_program, | |
| } | |
| } | |
| async fn run_sampling_request( | |
| sess: Arc<Session>, | |
| step_context: Arc<StepContext>, | |
| turn_store: Arc<codex_extension_api::ExtensionData>, | |
| turn_diff_tracker: SharedTurnDiffTracker, | |
| client_session: &mut ModelClientSession, | |
| responses_metadata: &CodexResponsesMetadata, | |
| input: Vec<ResponseItem>, | |
| cancellation_token: CancellationToken, | |
| ) -> CodexResult<(SamplingRequestResult, Vec<ResponseItem>)> { | |
| let turn_context = Arc::clone(&step_context.turn); | |
| let base_instructions = sess.get_prompt_base_instructions().await; | |
| let tool_runtime = ToolCallRuntime::new( | |
| Arc::clone(&sess), | |
| Arc::clone(&step_context), | |
| Arc::clone(&turn_diff_tracker), | |
| ); | |
| let _code_mode_worker = sess.services.code_mode_service.start_turn_worker( | |
| &sess, | |
| Arc::clone(&step_context), | |
| Arc::clone(&turn_diff_tracker), | |
| ); | |
| let max_retries = turn_context.provider.info().stream_max_retries(); | |
| let mut retry_state = ResponsesStreamRetryState::default(); | |
| let mut initial_input = Some(input); | |
| let mut original_input = None; | |
| let mut executed_tool_calls_by_output = HashMap::new(); | |
| loop { | |
| // Running code-mode cells can request review while this response is in flight. | |
| // Keep the latest received ID until response.created replaces it. | |
| let prompt_input = if let Some(input) = initial_input.take() { | |
| input | |
| } else { | |
| sess.clone_history() | |
| .await | |
| .for_prompt(&step_context.settings.model_info.input_modalities) | |
| }; | |
| let mut prompt_input = prompt_input; | |
| sess.services | |
| .executed_tool_calls | |
| .attach_to_prompt(&mut prompt_input, &mut executed_tool_calls_by_output); | |
| let prompt = build_prompt( | |
| prompt_input, | |
| step_context.as_ref(), | |
| base_instructions.clone(), | |
| ); | |
| if crate::guardian::is_basic_session_source(&turn_context.session_source) { | |
| crate::guardian::check_guardian_prompt_budget( | |
| &sess, | |
| &prompt, | |
| &turn_context.config, | |
| &step_context.settings.model_info, | |
| responses_metadata, | |
| )?; | |
| } | |
| let err = match try_run_sampling_request( | |
| tool_runtime.clone(), | |
| Arc::clone(&sess), | |
| Arc::clone(&step_context), | |
| Arc::clone(&turn_store), | |
| client_session, | |
| responses_metadata, | |
| Arc::clone(&turn_diff_tracker), | |
| &prompt, | |
| cancellation_token.child_token(), | |
| ) | |
| .await | |
| { | |
| Ok(output) => { | |
| return Ok((output, original_input.unwrap_or(prompt.input))); | |
| } | |
| Err(err) => match err.details() { | |
| CodexErrorDetails::ContextWindowExceeded => { | |
| sess.set_total_tokens_full(&turn_context).await; | |
| return Err(err); | |
| } | |
| CodexErrorDetails::UsageLimitReached(e) => { | |
| let rate_limits = e.rate_limits.clone(); | |
| if let Some(rate_limits) = rate_limits { | |
| sess.update_rate_limits(&turn_context, *rate_limits).await; | |
| } | |
| return Err(err); | |
| } | |
| _ => err, | |
| }, | |
| }; | |
| if original_input.is_none() { | |
| original_input = Some(prompt.input); | |
| } | |
| if !err.is_retryable() { | |
| return Err(err); | |
| } | |
| handle_retryable_response_stream_error( | |
| &mut retry_state, | |
| max_retries, | |
| err, | |
| client_session, | |
| &sess, | |
| &turn_context, | |
| ResponsesStreamRequest::Sampling, | |
| ) | |
| .await?; | |
| turn_context.turn_timing_state.record_sampling_retry(); | |
| } | |
| } | |
| pub(crate) struct PreparedToolRecommendations { | |
| auth: Option<CodexAuth>, | |
| endpoint_candidates: Option<Vec<DiscoverableTool>>, | |
| } | |
| pub(crate) async fn prepare_tool_recommendations( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| ) -> PreparedToolRecommendations { | |
| let loaded_plugins = sess | |
| .services | |
| .plugins_manager | |
| .plugins_for_config(&turn_context.config.plugins_config_input()) | |
| .instrument(trace_span!("built_tools.load_plugins")) | |
| .await | |
| .without_plugins(&turn_context.disabled_plugin_ids); | |
| let tool_suggest_is_enabled = tool_suggest_enabled(turn_context); | |
| let auth = if tool_suggest_is_enabled { | |
| sess.services.auth_manager.auth().await | |
| } else { | |
| None | |
| }; | |
| let endpoint_candidates = if tool_suggest_is_enabled { | |
| let plugins_config = turn_context.config.plugins_config_input(); | |
| sess.services | |
| .plugins_manager | |
| .recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput { | |
| plugins_config: &plugins_config, | |
| loaded_plugins: &loaded_plugins, | |
| auth: auth.as_ref(), | |
| disabled_tools: &turn_context.config.tool_suggest.disabled_tools, | |
| app_server_client_name: turn_context.app_server_client_name.as_deref(), | |
| }) | |
| .await | |
| } else { | |
| None | |
| }; | |
| PreparedToolRecommendations { | |
| auth, | |
| endpoint_candidates, | |
| } | |
| } | |
| pub(crate) async fn built_tools( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| model_info: &codex_protocol::openai_models::ModelInfo, | |
| model_messages: Option<&ModelMessages>, | |
| environments: &TurnEnvironmentSnapshot, | |
| mcp: &Arc<codex_mcp::McpBinding>, | |
| step_store: &ExtensionData, | |
| prepared_recommendations: PreparedToolRecommendations, | |
| ) -> CodexResult<Arc<ToolRouter>> { | |
| let all_mcp_tools = mcp.tools(); | |
| let connector_snapshot = mcp.config().connector_snapshot.clone(); | |
| let apps_enabled = turn_context.apps_enabled(); | |
| let accessible_connectors = | |
| apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(all_mcp_tools)); | |
| let tool_suggest_is_enabled = tool_suggest_enabled(turn_context); | |
| let PreparedToolRecommendations { | |
| auth, | |
| endpoint_candidates: endpoint_recommended_plugin_candidates, | |
| } = prepared_recommendations; | |
| let tool_suggest_candidates = | |
| if let Some(recommended_plugin_candidates) = endpoint_recommended_plugin_candidates { | |
| Some(ToolSuggestCandidates { | |
| tools: recommended_plugin_candidates, | |
| presentation: ToolSuggestPresentation::RecommendationContext, | |
| }) | |
| } else { | |
| let loaded_plugin_app_connector_ids = connector_snapshot | |
| .connector_ids() | |
| .iter() | |
| .map(|connector_id| connector_id.0.clone()) | |
| .collect::<Vec<_>>(); | |
| async { | |
| if apps_enabled && tool_suggest_is_enabled { | |
| if let Some(accessible_connectors) = accessible_connectors.as_ref() { | |
| match connectors::list_tool_suggest_discoverable_tools_with_auth( | |
| &turn_context.config, | |
| sess.services.plugins_manager.as_ref(), | |
| auth.as_ref(), | |
| accessible_connectors.as_slice(), | |
| &loaded_plugin_app_connector_ids, | |
| ) | |
| .await | |
| .map(|discoverable_tools| { | |
| filter_request_plugin_install_discoverable_tools_for_client( | |
| discoverable_tools, | |
| turn_context.app_server_client_name.as_deref(), | |
| ) | |
| }) { | |
| Ok(discoverable_tools) if discoverable_tools.is_empty() => None, | |
| Ok(discoverable_tools) => Some(ToolSuggestCandidates { | |
| tools: discoverable_tools, | |
| presentation: ToolSuggestPresentation::ListTool, | |
| }), | |
| Err(err) => { | |
| warn!("failed to load discoverable tool suggestions: {err:#}"); | |
| None | |
| } | |
| } | |
| } else { | |
| None | |
| } | |
| } else { | |
| None | |
| } | |
| } | |
| .instrument(trace_span!("built_tools.load_discoverable_tools")) | |
| .await | |
| }; | |
| Ok(Arc::new(build_tool_router( | |
| sess, | |
| turn_context, | |
| model_info, | |
| model_messages, | |
| environments, | |
| mcp, | |
| apps_enabled, | |
| step_store, | |
| tool_suggest_candidates.as_ref(), | |
| )?)) | |
| } | |
| struct SamplingRequestResult { | |
| needs_follow_up: bool, | |
| last_agent_message: Option<String>, | |
| } | |
| /// Ephemeral per-response state for streaming a single proposed plan. | |
| /// This is intentionally not persisted or stored in session/state since it | |
| /// only exists while a response is actively streaming. The final plan text | |
| /// is extracted from the completed assistant message. | |
| /// Tracks a single proposed plan item across a streaming response. | |
| struct ProposedPlanItemState { | |
| item_id: String, | |
| started: bool, | |
| completed: bool, | |
| } | |
| /// Aggregated state used only while streaming a plan-mode response. | |
| /// Includes per-item parsers, deferred agent message bookkeeping, and the plan item lifecycle. | |
| struct PlanModeStreamState { | |
| /// Agent message items started by the model but deferred until we see non-plan text. | |
| pending_agent_message_items: HashMap<String, TurnItem>, | |
| /// Agent message items whose start notification has been emitted. | |
| started_agent_message_items: HashSet<String>, | |
| /// Leading whitespace buffered until we see non-whitespace text for an item. | |
| leading_whitespace_by_item: HashMap<String, String>, | |
| /// Tracks plan item lifecycle while streaming plan output. | |
| plan_item_state: ProposedPlanItemState, | |
| } | |
| impl PlanModeStreamState { | |
| fn new(turn_id: &str) -> Self { | |
| Self { | |
| pending_agent_message_items: HashMap::new(), | |
| started_agent_message_items: HashSet::new(), | |
| leading_whitespace_by_item: HashMap::new(), | |
| plan_item_state: ProposedPlanItemState::new(turn_id), | |
| } | |
| } | |
| } | |
| pub(super) struct AssistantMessageStreamParsers { | |
| plan_mode: bool, | |
| parsers_by_item: HashMap<String, AssistantTextStreamParser>, | |
| } | |
| type ParsedAssistantTextDelta = AssistantTextChunk; | |
| impl AssistantMessageStreamParsers { | |
| pub(super) fn new(plan_mode: bool) -> Self { | |
| Self { | |
| plan_mode, | |
| parsers_by_item: HashMap::new(), | |
| } | |
| } | |
| fn parser_mut(&mut self, item_id: &str) -> &mut AssistantTextStreamParser { | |
| let plan_mode = self.plan_mode; | |
| self.parsers_by_item | |
| .entry(item_id.to_string()) | |
| .or_insert_with(|| AssistantTextStreamParser::new(plan_mode)) | |
| } | |
| pub(super) fn seed_item_text(&mut self, item_id: &str, text: &str) -> ParsedAssistantTextDelta { | |
| if text.is_empty() { | |
| return ParsedAssistantTextDelta::default(); | |
| } | |
| self.parser_mut(item_id).push_str(text) | |
| } | |
| pub(super) fn parse_delta(&mut self, item_id: &str, delta: &str) -> ParsedAssistantTextDelta { | |
| self.parser_mut(item_id).push_str(delta) | |
| } | |
| pub(super) fn finish_item(&mut self, item_id: &str) -> ParsedAssistantTextDelta { | |
| let Some(mut parser) = self.parsers_by_item.remove(item_id) else { | |
| return ParsedAssistantTextDelta::default(); | |
| }; | |
| parser.finish() | |
| } | |
| fn drain_finished(&mut self) -> Vec<(String, ParsedAssistantTextDelta)> { | |
| let parsers_by_item = std::mem::take(&mut self.parsers_by_item); | |
| parsers_by_item | |
| .into_iter() | |
| .map(|(item_id, mut parser)| (item_id, parser.finish())) | |
| .collect() | |
| } | |
| } | |
| impl ProposedPlanItemState { | |
| fn new(turn_id: &str) -> Self { | |
| Self { | |
| item_id: format!("{turn_id}-plan"), | |
| started: false, | |
| completed: false, | |
| } | |
| } | |
| async fn start(&mut self, sess: &Session, turn_context: &TurnContext) { | |
| if self.started || self.completed { | |
| return; | |
| } | |
| self.started = true; | |
| let item = TurnItem::Plan(PlanItem { | |
| id: self.item_id.clone(), | |
| text: String::new(), | |
| }); | |
| sess.emit_turn_item_started(turn_context, &item).await; | |
| } | |
| async fn push_delta(&mut self, sess: &Session, turn_context: &TurnContext, delta: &str) { | |
| if self.completed { | |
| return; | |
| } | |
| if delta.is_empty() { | |
| return; | |
| } | |
| let event = PlanDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id: self.item_id.clone(), | |
| delta: delta.to_string(), | |
| }; | |
| sess.send_event(turn_context, EventMsg::PlanDelta(event)) | |
| .await; | |
| } | |
| async fn complete_with_text( | |
| &mut self, | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| text: String, | |
| ) { | |
| if self.completed || !self.started { | |
| return; | |
| } | |
| self.completed = true; | |
| let item = TurnItem::Plan(PlanItem { | |
| id: self.item_id.clone(), | |
| text, | |
| }); | |
| sess.emit_turn_item_completed(turn_context, item).await; | |
| } | |
| } | |
| /// In plan mode we defer agent message starts until the parser emits non-plan | |
| /// text. The parser buffers each line until it can rule out a tag prefix, so | |
| /// plan-only outputs never show up as empty assistant messages. | |
| async fn maybe_emit_pending_agent_message_start( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| state: &mut PlanModeStreamState, | |
| item_id: &str, | |
| ) { | |
| if state.started_agent_message_items.contains(item_id) { | |
| return; | |
| } | |
| if let Some(item) = state.pending_agent_message_items.remove(item_id) { | |
| sess.emit_turn_item_started(turn_context, &item).await; | |
| state | |
| .started_agent_message_items | |
| .insert(item_id.to_string()); | |
| } | |
| } | |
| /// Agent messages are text-only today; concatenate all text entries. | |
| pub(super) fn agent_message_text(item: &codex_protocol::items::AgentMessageItem) -> String { | |
| item.content | |
| .iter() | |
| .map(|entry| match entry { | |
| codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), | |
| }) | |
| .collect() | |
| } | |
| pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option<MessagePhase>)> { | |
| match msg { | |
| EventMsg::ElicitationRequest(request) | |
| if matches!( | |
| &request.request, | |
| codex_protocol::approvals::ElicitationRequest::UserVerification { .. } | |
| ) => | |
| { | |
| Some((UserVerificationNotice.render(), None)) | |
| } | |
| EventMsg::AgentMessage(event) => Some((event.message.clone(), event.phase.clone())), | |
| EventMsg::ItemCompleted(event) => match &event.item { | |
| TurnItem::AgentMessage(item) => Some((agent_message_text(item), item.phase.clone())), | |
| _ => None, | |
| }, | |
| EventMsg::ExecApprovalRequest(_) | |
| | EventMsg::RequestPermissions(_) | |
| | EventMsg::ApplyPatchApprovalRequest(_) | |
| | EventMsg::RequestUserInput(_) | |
| | EventMsg::ElicitationRequest(_) => { | |
| let message = if matches!( | |
| msg, | |
| EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | |
| ) { | |
| "I need your input. Please respond in the app." | |
| } else { | |
| "I need your approval to continue. Please review the request in the app." | |
| }; | |
| serde_json::to_string(msg) | |
| .ok() | |
| .map(|request| (format!("{message}\n\n{request}"), None)) | |
| } | |
| EventMsg::Error(_) | |
| | EventMsg::Warning(_) | |
| | EventMsg::AuthRecoveryStarted(_) | |
| | EventMsg::AuthRecoveryCompleted(_) | |
| | EventMsg::GuardianWarning(_) | |
| | EventMsg::RealtimeConversationStarted(_) | |
| | EventMsg::RealtimeConversationSdp(_) | |
| | EventMsg::RealtimeConversationRealtime(_) | |
| | EventMsg::RealtimeConversationClosed(_) | |
| | EventMsg::ModelReroute(_) | |
| | EventMsg::ModelVerification(_) | |
| | EventMsg::TurnModerationMetadata(_) | |
| | EventMsg::SafetyBuffering(_) | |
| | EventMsg::ContextCompacted(_) | |
| | EventMsg::ThreadRolledBack(_) | |
| | EventMsg::TurnStarted(_) | |
| | EventMsg::ThreadSettingsApplied(_) | |
| | EventMsg::TurnComplete(_) | |
| | EventMsg::TokenCount(_) | |
| | EventMsg::UserMessage(_) | |
| | EventMsg::AgentReasoning(_) | |
| | EventMsg::AgentReasoningRawContent(_) | |
| | EventMsg::AgentReasoningSectionBreak(_) | |
| | EventMsg::SessionConfigured(_) | |
| | EventMsg::EnvironmentConnected(_) | |
| | EventMsg::EnvironmentDisconnected(_) | |
| | EventMsg::ThreadGoalUpdated(_) | |
| | EventMsg::ThreadQueueChanged(_) | |
| | EventMsg::McpStartupUpdate(_) | |
| | EventMsg::McpStartupComplete(_) | |
| | EventMsg::McpToolCallBegin(_) | |
| | EventMsg::McpToolCallEnd(_) | |
| | EventMsg::WebSearchBegin(_) | |
| | EventMsg::WebSearchEnd(_) | |
| | EventMsg::ExecCommandBegin(_) | |
| | EventMsg::ExecCommandOutputDelta(_) | |
| | EventMsg::TerminalInteraction(_) | |
| | EventMsg::ExecCommandEnd(_) | |
| | EventMsg::PatchApplyBegin(_) | |
| | EventMsg::PatchApplyUpdated(_) | |
| | EventMsg::PatchApplyEnd(_) | |
| | EventMsg::ImageGenerationBegin(_) | |
| | EventMsg::ImageGenerationEnd(_) | |
| | EventMsg::ViewImageToolCall(_) | |
| | EventMsg::DynamicToolCallRequest(_) | |
| | EventMsg::DynamicToolCallResponse(_) | |
| | EventMsg::GuardianAssessment(_) | |
| | EventMsg::DeprecationNotice(_) | |
| | EventMsg::StreamError(_) | |
| | EventMsg::TurnDiff(_) | |
| | EventMsg::RealtimeConversationListVoicesResponse(_) | |
| | EventMsg::PlanUpdate(_) | |
| | EventMsg::TurnAborted(_) | |
| | EventMsg::ShutdownComplete | |
| | EventMsg::EnteredReviewMode(_) | |
| | EventMsg::ExitedReviewMode(_) | |
| | EventMsg::RawResponseItem(_) | |
| | EventMsg::RawResponseCompleted(_) | |
| | EventMsg::ItemStarted(_) | |
| | EventMsg::HookStarted(_) | |
| | EventMsg::HookCompleted(_) | |
| | EventMsg::AgentMessageContentDelta(_) | |
| | EventMsg::PlanDelta(_) | |
| | EventMsg::ReasoningContentDelta(_) | |
| | EventMsg::ReasoningRawContentDelta(_) | |
| | EventMsg::CollabAgentSpawnBegin(_) | |
| | EventMsg::CollabAgentSpawnEnd(_) | |
| | EventMsg::CollabAgentInteractionBegin(_) | |
| | EventMsg::CollabAgentInteractionEnd(_) | |
| | EventMsg::CollabWaitingBegin(_) | |
| | EventMsg::CollabWaitingEnd(_) | |
| | EventMsg::CollabCloseBegin(_) | |
| | EventMsg::CollabCloseEnd(_) | |
| | EventMsg::CollabResumeBegin(_) | |
| | EventMsg::CollabResumeEnd(_) | |
| | EventMsg::SubAgentActivity(_) => None, | |
| } | |
| } | |
| /// Split the stream into normal assistant text vs. proposed plan content. | |
| /// Normal text becomes AgentMessage deltas; plan content becomes PlanDelta + | |
| /// TurnItem::Plan. | |
| async fn handle_plan_segments( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| state: &mut PlanModeStreamState, | |
| item_id: &str, | |
| segments: Vec<ProposedPlanSegment>, | |
| ) { | |
| for segment in segments { | |
| match segment { | |
| ProposedPlanSegment::Normal(delta) => { | |
| if delta.is_empty() { | |
| continue; | |
| } | |
| let has_non_whitespace = delta.chars().any(|ch| !ch.is_whitespace()); | |
| if !has_non_whitespace && !state.started_agent_message_items.contains(item_id) { | |
| let entry = state | |
| .leading_whitespace_by_item | |
| .entry(item_id.to_string()) | |
| .or_default(); | |
| entry.push_str(&delta); | |
| continue; | |
| } | |
| let delta = if !state.started_agent_message_items.contains(item_id) { | |
| if let Some(prefix) = state.leading_whitespace_by_item.remove(item_id) { | |
| format!("{prefix}{delta}") | |
| } else { | |
| delta | |
| } | |
| } else { | |
| delta | |
| }; | |
| maybe_emit_pending_agent_message_start(sess, turn_context, state, item_id).await; | |
| let event = AgentMessageContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id: item_id.to_string(), | |
| delta, | |
| }; | |
| sess.send_event(turn_context, EventMsg::AgentMessageContentDelta(event)) | |
| .await; | |
| } | |
| ProposedPlanSegment::ProposedPlanStart => { | |
| if !state.plan_item_state.completed { | |
| state.plan_item_state.start(sess, turn_context).await; | |
| } | |
| } | |
| ProposedPlanSegment::ProposedPlanDelta(delta) => { | |
| if !state.plan_item_state.completed { | |
| if !state.plan_item_state.started { | |
| state.plan_item_state.start(sess, turn_context).await; | |
| } | |
| state | |
| .plan_item_state | |
| .push_delta(sess, turn_context, &delta) | |
| .await; | |
| } | |
| } | |
| ProposedPlanSegment::ProposedPlanEnd => {} | |
| } | |
| } | |
| } | |
| async fn emit_streamed_assistant_text_delta( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| plan_mode_state: Option<&mut PlanModeStreamState>, | |
| item_id: &str, | |
| parsed: ParsedAssistantTextDelta, | |
| ) { | |
| if parsed.is_empty() { | |
| return; | |
| } | |
| if !parsed.citations.is_empty() { | |
| // Citation extraction is intentionally local for now; we strip citations from display text | |
| // but do not yet surface them in protocol events. | |
| let _citations = parsed.citations; | |
| } | |
| if let Some(state) = plan_mode_state { | |
| if !parsed.plan_segments.is_empty() { | |
| handle_plan_segments(sess, turn_context, state, item_id, parsed.plan_segments).await; | |
| } | |
| return; | |
| } | |
| if parsed.visible_text.is_empty() { | |
| return; | |
| } | |
| let event = AgentMessageContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id: item_id.to_string(), | |
| delta: parsed.visible_text, | |
| }; | |
| sess.send_event(turn_context, EventMsg::AgentMessageContentDelta(event)) | |
| .await; | |
| } | |
| /// Flush buffered assistant text parser state when an assistant message item ends. | |
| async fn flush_assistant_text_segments_for_item( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| plan_mode_state: Option<&mut PlanModeStreamState>, | |
| parsers: &mut AssistantMessageStreamParsers, | |
| item_id: &str, | |
| ) { | |
| let parsed = parsers.finish_item(item_id); | |
| emit_streamed_assistant_text_delta(sess, turn_context, plan_mode_state, item_id, parsed).await; | |
| } | |
| /// Flush any remaining buffered assistant text parser state at response completion. | |
| async fn flush_assistant_text_segments_all( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| mut plan_mode_state: Option<&mut PlanModeStreamState>, | |
| parsers: &mut AssistantMessageStreamParsers, | |
| ) { | |
| for (item_id, parsed) in parsers.drain_finished() { | |
| emit_streamed_assistant_text_delta( | |
| sess, | |
| turn_context, | |
| plan_mode_state.as_deref_mut(), | |
| &item_id, | |
| parsed, | |
| ) | |
| .await; | |
| } | |
| } | |
| /// Emit completion for plan items by parsing the finalized assistant message. | |
| async fn maybe_complete_plan_item_from_message( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| state: &mut PlanModeStreamState, | |
| item: &ResponseItem, | |
| ) { | |
| if let ResponseItem::Message { role, content, .. } = item | |
| && role == "assistant" | |
| { | |
| let mut text = String::new(); | |
| for entry in content { | |
| if let ContentItem::OutputText { text: chunk } = entry { | |
| text.push_str(chunk); | |
| } | |
| } | |
| if let Some(plan_text) = extract_proposed_plan_text(&text) { | |
| let (plan_text, _citations) = strip_citations(&plan_text); | |
| if !state.plan_item_state.started { | |
| state.plan_item_state.start(sess, turn_context).await; | |
| } | |
| state | |
| .plan_item_state | |
| .complete_with_text(sess, turn_context, plan_text) | |
| .await; | |
| } | |
| } | |
| } | |
| /// Emit a completed agent message in plan mode, respecting deferred starts. | |
| async fn emit_agent_message_in_plan_mode( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| agent_message: codex_protocol::items::AgentMessageItem, | |
| state: &mut PlanModeStreamState, | |
| ) { | |
| let agent_message_id = agent_message.id.clone(); | |
| let text = agent_message_text(&agent_message); | |
| if text.trim().is_empty() { | |
| state.pending_agent_message_items.remove(&agent_message_id); | |
| state.started_agent_message_items.remove(&agent_message_id); | |
| return; | |
| } | |
| maybe_emit_pending_agent_message_start(sess, turn_context, state, &agent_message_id).await; | |
| if !state | |
| .started_agent_message_items | |
| .contains(&agent_message_id) | |
| { | |
| let start_item = state | |
| .pending_agent_message_items | |
| .remove(&agent_message_id) | |
| .unwrap_or_else(|| { | |
| TurnItem::AgentMessage(codex_protocol::items::AgentMessageItem { | |
| id: agent_message_id.clone(), | |
| content: Vec::new(), | |
| phase: None, | |
| memory_citation: None, | |
| delivery: None, | |
| questions: None, | |
| }) | |
| }); | |
| sess.emit_turn_item_started(turn_context, &start_item).await; | |
| state | |
| .started_agent_message_items | |
| .insert(agent_message_id.clone()); | |
| } | |
| sess.emit_turn_item_completed(turn_context, TurnItem::AgentMessage(agent_message)) | |
| .await; | |
| state.started_agent_message_items.remove(&agent_message_id); | |
| } | |
| /// Emit completion for a plan-mode turn item, handling agent messages specially. | |
| async fn emit_turn_item_in_plan_mode( | |
| sess: &Session, | |
| turn_context: &TurnContext, | |
| turn_item: TurnItem, | |
| previously_active_item: Option<&TurnItem>, | |
| state: &mut PlanModeStreamState, | |
| ) { | |
| match turn_item { | |
| TurnItem::AgentMessage(agent_message) => { | |
| emit_agent_message_in_plan_mode(sess, turn_context, agent_message, state).await; | |
| } | |
| _ => { | |
| if previously_active_item.is_none() { | |
| sess.emit_turn_item_started(turn_context, &turn_item).await; | |
| } | |
| sess.emit_turn_item_completed(turn_context, turn_item).await; | |
| } | |
| } | |
| } | |
| /// Handle a completed assistant response item in plan mode, returning true if handled. | |
| async fn handle_assistant_item_done_in_plan_mode( | |
| sess: &Session, | |
| step_context: &StepContext, | |
| turn_store: &codex_extension_api::ExtensionData, | |
| item: &ResponseItem, | |
| state: &mut PlanModeStreamState, | |
| previously_active_item: Option<&TurnItem>, | |
| last_agent_message: &mut Option<String>, | |
| ) -> bool { | |
| let turn_context = &step_context.turn; | |
| if let ResponseItem::Message { role, .. } = item | |
| && role == "assistant" | |
| { | |
| maybe_complete_plan_item_from_message(sess, turn_context, state, item).await; | |
| let mut finalized_facts = None; | |
| if let Some(finalized_turn_item) = finalize_non_tool_response_item( | |
| sess, | |
| TurnItemContributorPolicy::Run(turn_store), | |
| item, | |
| /*plan_mode*/ true, | |
| ) | |
| .await | |
| { | |
| finalized_facts = Some(finalized_turn_item.facts.clone()); | |
| emit_turn_item_in_plan_mode( | |
| sess, | |
| turn_context, | |
| finalized_turn_item.turn_item, | |
| previously_active_item, | |
| state, | |
| ) | |
| .await; | |
| } | |
| let final_last_agent_message = finalized_facts | |
| .as_ref() | |
| .and_then(|facts| facts.last_agent_message.clone()); | |
| record_completed_response_item_with_finalized_facts( | |
| sess, | |
| step_context, | |
| item, | |
| finalized_facts.as_ref(), | |
| ) | |
| .await; | |
| if let Some(agent_message) = final_last_agent_message { | |
| *last_agent_message = Some(agent_message); | |
| } | |
| return true; | |
| } | |
| false | |
| } | |
| async fn drain_in_flight( | |
| in_flight: &mut FuturesOrdered<InFlightFuture<'static>>, | |
| sess: Arc<Session>, | |
| step_context: &StepContext, | |
| ) -> CodexResult<()> { | |
| let turn_context = &step_context.turn; | |
| while let Some(res) = in_flight.next().await { | |
| match res { | |
| Ok(envelope) => { | |
| mark_thread_memory_mode_polluted_if_external_context( | |
| sess.as_ref(), | |
| turn_context.as_ref(), | |
| &envelope.item, | |
| ) | |
| .await; | |
| sess.record_annotated_conversation_items( | |
| turn_context, | |
| &step_context.settings.model_info, | |
| vec![envelope], | |
| ) | |
| .await; | |
| } | |
| Err(err) => { | |
| error_or_panic(format!("in-flight tool future failed during drain: {err}")); | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn assign_missing_streamed_response_item_id( | |
| item: &mut ResponseItem, | |
| active_item: Option<&TurnItem>, | |
| ) { | |
| if item.id().is_some_and(|id| !id.is_empty()) { | |
| return; | |
| } | |
| let active_item_id = active_item | |
| .map(|item| ResponseItemId::from_server(item.id())) | |
| .filter(|item_id| !item_id.is_empty()); | |
| item.set_id(active_item_id); | |
| Session::assign_missing_response_item_id(item); | |
| } | |
| async fn try_run_sampling_request( | |
| tool_runtime: ToolCallRuntime, | |
| sess: Arc<Session>, | |
| step_context: Arc<StepContext>, | |
| turn_store: Arc<codex_extension_api::ExtensionData>, | |
| client_session: &mut ModelClientSession, | |
| responses_metadata: &CodexResponsesMetadata, | |
| turn_diff_tracker: SharedTurnDiffTracker, | |
| prompt: &Prompt, | |
| cancellation_token: CancellationToken, | |
| ) -> CodexResult<SamplingRequestResult> { | |
| let turn_context = Arc::clone(&step_context.turn); | |
| feedback_tags!( | |
| model = step_context.settings.model_info.slug.clone(), | |
| approval_policy = turn_context.approval_policy(), | |
| sandbox_policy = &turn_context.sandbox_policy(), | |
| effort = step_context.settings.reasoning_effort(), | |
| auth_mode = sess.services.auth_manager.auth_mode(), | |
| features = sess.features.enabled_features(), | |
| ); | |
| let inference_trace = sess.services.rollout_thread_trace.inference_trace_context( | |
| turn_context.sub_id.as_str(), | |
| step_context.settings.model_info.slug.as_str(), | |
| turn_context.provider.info().name.as_str(), | |
| ); | |
| let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); | |
| let uses_sequential_cutoff_reasoning_summaries = turn_context | |
| .config | |
| .features | |
| .enabled(Feature::ConcurrentReasoningSummaries) | |
| && turn_context.provider.info().is_openai(); | |
| let mut stream = client_session | |
| .stream( | |
| prompt, | |
| &step_context.settings.model_info, | |
| &step_context.session_telemetry, | |
| sess.reasoning_effort_for_request( | |
| &step_context.settings, | |
| super::RequestEffortUsage::Sampling, | |
| ) | |
| .await, | |
| step_context.settings.reasoning_summary, | |
| step_context.settings.service_tier.clone(), | |
| responses_metadata, | |
| &inference_trace, | |
| ) | |
| .instrument(trace_span!("stream_request")) | |
| .or_cancel(&cancellation_token) | |
| .await??; | |
| let mut in_flight: FuturesOrdered<InFlightFuture<'static>> = FuturesOrdered::new(); | |
| let mut needs_follow_up = false; | |
| let mut last_agent_message: Option<String> = None; | |
| let mut active_item: Option<TurnItem> = None; | |
| let mut active_tool_argument_diff_consumer: Option<( | |
| String, | |
| Box<dyn ToolArgumentDiffConsumer>, | |
| )> = None; | |
| let mut should_emit_turn_diff = false; | |
| let mut should_emit_token_count = false; | |
| const MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE: usize = 256; | |
| let mut analytics_tool_call_ids = Vec::new(); | |
| let reasoning_effort = step_context | |
| .settings | |
| .reasoning_effort() | |
| .or(step_context | |
| .settings | |
| .model_info | |
| .default_reasoning_level | |
| .as_ref()) | |
| .map(std::string::ToString::to_string) | |
| .unwrap_or_else(|| "default".to_string()); | |
| let plan_mode = turn_context.mode() == ModeKind::Plan; | |
| let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); | |
| let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); | |
| let defer_streamed_turn_items_for_contributors = | |
| !sess.services.extensions.turn_item_contributors().is_empty(); | |
| let mut active_item_is_streaming_to_client = false; | |
| let receiving_span = trace_span!("receiving_stream"); | |
| let outcome: CodexResult<SamplingRequestResult> = loop { | |
| let handle_responses = trace_span!( | |
| parent: &receiving_span, | |
| "handle_responses", | |
| otel.name = field::Empty, | |
| tool_name = field::Empty, | |
| from = field::Empty, | |
| codex.request.reasoning_effort = %reasoning_effort, | |
| gen_ai.usage.input_tokens = field::Empty, | |
| gen_ai.usage.cache_read.input_tokens = field::Empty, | |
| gen_ai.usage.cache_write.input_tokens = field::Empty, | |
| gen_ai.usage.output_tokens = field::Empty, | |
| codex.usage.reasoning_output_tokens = field::Empty, | |
| codex.usage.total_tokens = field::Empty, | |
| ); | |
| let event = match stream | |
| .next() | |
| .instrument(trace_span!(parent: &handle_responses, "receiving")) | |
| .or_cancel(&cancellation_token) | |
| .await | |
| { | |
| Ok(event) => event, | |
| Err(codex_async_utils::CancelErr::Cancelled) => { | |
| break Err(CodexErr::TurnAborted); | |
| } | |
| }; | |
| let event = match event { | |
| Some(Ok(event)) => event, | |
| Some(Err(err)) => break Err(err), | |
| None => { | |
| break Err(CodexErr::Stream( | |
| "stream closed before response.completed".into(), | |
| )); | |
| } | |
| }; | |
| sess.services | |
| .session_telemetry | |
| .record_responses(&handle_responses, &event); | |
| record_turn_ttft_metric(&turn_context, &event).await; | |
| match event { | |
| ResponseEvent::Created { response_id } => { | |
| if let Some(response_id) = response_id { | |
| turn_context | |
| .extension_data | |
| .insert(codex_api::ResponseId(response_id)); | |
| } | |
| } | |
| ResponseEvent::OutputItemDone(mut item) => { | |
| assign_missing_streamed_response_item_id(&mut item, active_item.as_ref()); | |
| if analytics_tool_call_ids.len() < MAX_ANALYTICS_TOOL_CALL_IDS_PER_RESPONSE { | |
| let call_id = match &item { | |
| ResponseItem::FunctionCall { call_id, .. } | |
| | ResponseItem::CustomToolCall { call_id, .. } => Some(call_id.as_str()), | |
| ResponseItem::ToolSearchCall { call_id, .. } | |
| | ResponseItem::LocalShellCall { call_id, .. } => call_id.as_deref(), | |
| ResponseItem::WebSearchCall { id, .. } | |
| | ResponseItem::ImageGenerationCall { id, .. } => { | |
| id.as_ref().map(codex_protocol::ResponseItemId::as_str) | |
| } | |
| _ => None, | |
| }; | |
| if let Some(call_id) = call_id { | |
| analytics_tool_call_ids.push(call_id.to_string()); | |
| } | |
| } | |
| if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() | |
| && let Ok(Some(event)) = consumer.finish() | |
| { | |
| sess.send_event(&turn_context, event).await; | |
| } | |
| let previously_active_item = active_item.take(); | |
| let previously_streamed_item = if active_item_is_streaming_to_client { | |
| previously_active_item | |
| } else { | |
| None | |
| }; | |
| active_item_is_streaming_to_client = false; | |
| if let Some(previous) = previously_streamed_item.as_ref() | |
| && matches!(previous, TurnItem::AgentMessage(_)) | |
| { | |
| let item_id = previous.id(); | |
| flush_assistant_text_segments_for_item( | |
| &sess, | |
| &turn_context, | |
| plan_mode_state.as_mut(), | |
| &mut assistant_message_stream_parsers, | |
| &item_id, | |
| ) | |
| .await; | |
| } | |
| if let Some(state) = plan_mode_state.as_mut() | |
| && handle_assistant_item_done_in_plan_mode( | |
| &sess, | |
| &step_context, | |
| turn_store.as_ref(), | |
| &item, | |
| state, | |
| previously_streamed_item.as_ref(), | |
| &mut last_agent_message, | |
| ) | |
| .await | |
| { | |
| continue; | |
| } | |
| let mut ctx = HandleOutputCtx { | |
| sess: sess.clone(), | |
| step_context: Arc::clone(&step_context), | |
| turn_store: Arc::clone(&turn_store), | |
| tool_runtime: tool_runtime.clone(), | |
| cancellation_token: cancellation_token.child_token(), | |
| }; | |
| let preempt_for_mailbox_mail = match &item { | |
| ResponseItem::Message { role, phase, .. } => { | |
| role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) | |
| } | |
| ResponseItem::Reasoning { .. } => true, | |
| ResponseItem::AgentMessage { .. } => false, | |
| ResponseItem::AdditionalTools { .. } | |
| | ResponseItem::LocalShellCall { .. } | |
| | ResponseItem::FunctionCall { .. } | |
| | ResponseItem::ToolSearchCall { .. } | |
| | ResponseItem::FunctionCallOutput { .. } | |
| | ResponseItem::CustomToolCall { .. } | |
| | ResponseItem::CustomToolCallOutput { .. } | |
| | ResponseItem::ToolSearchOutput { .. } | |
| | ResponseItem::WebSearchCall { .. } | |
| | ResponseItem::ImageGenerationCall { .. } | |
| | ResponseItem::Compaction { .. } | |
| | ResponseItem::ConfigurationUpdate { .. } | |
| | ResponseItem::CompactionTrigger { .. } | |
| | ResponseItem::ContextCompaction { .. } | |
| | ResponseItem::Other => false, | |
| }; | |
| let output_result = | |
| match handle_output_item_done(&mut ctx, item, previously_streamed_item) | |
| .instrument(handle_responses) | |
| .await | |
| { | |
| Ok(output_result) => output_result, | |
| Err(err) => break Err(err), | |
| }; | |
| if let Some(tool_future) = output_result.tool_future { | |
| in_flight.push_back(tool_future); | |
| } | |
| if let Some(agent_message) = output_result.last_agent_message { | |
| last_agent_message = Some(agent_message); | |
| } | |
| needs_follow_up |= output_result.needs_follow_up; | |
| // todo: remove before stabilizing multi-agent v2 | |
| if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await { | |
| break Ok(SamplingRequestResult { | |
| needs_follow_up: true, | |
| last_agent_message, | |
| }); | |
| } | |
| } | |
| ResponseEvent::OutputItemAdded(mut item) => { | |
| assign_missing_streamed_response_item_id(&mut item, /*active_item*/ None); | |
| if let ResponseItem::CustomToolCall { | |
| call_id, | |
| name, | |
| namespace, | |
| .. | |
| } = &item | |
| { | |
| let tool_name = ToolName::new(namespace.clone(), name.as_str()); | |
| active_tool_argument_diff_consumer = tool_runtime | |
| .create_diff_consumer(&tool_name) | |
| .map(|consumer| (call_id.clone(), consumer)); | |
| } else if matches!(&item, ResponseItem::FunctionCall { .. }) { | |
| active_tool_argument_diff_consumer = None; | |
| } | |
| if let Some(turn_item) = handle_non_tool_response_item( | |
| sess.as_ref(), | |
| TurnItemContributorPolicy::Skip, | |
| &item, | |
| plan_mode, | |
| ) | |
| .await | |
| { | |
| let mut turn_item = turn_item; | |
| let stream_item_to_client = !defer_streamed_turn_items_for_contributors; | |
| let mut seeded_parsed: Option<ParsedAssistantTextDelta> = None; | |
| let mut seeded_item_id: Option<String> = None; | |
| if stream_item_to_client | |
| && matches!(turn_item, TurnItem::AgentMessage(_)) | |
| && let Some(raw_text) = raw_assistant_output_text_from_item(&item) | |
| { | |
| let item_id = turn_item.id(); | |
| let mut seeded = | |
| assistant_message_stream_parsers.seed_item_text(&item_id, &raw_text); | |
| if let TurnItem::AgentMessage(agent_message) = &mut turn_item { | |
| agent_message.content = | |
| vec![codex_protocol::items::AgentMessageContent::Text { | |
| text: if plan_mode { | |
| String::new() | |
| } else { | |
| std::mem::take(&mut seeded.visible_text) | |
| }, | |
| }]; | |
| } | |
| seeded_parsed = plan_mode.then_some(seeded); | |
| seeded_item_id = Some(item_id); | |
| } | |
| if stream_item_to_client { | |
| if let Some(state) = plan_mode_state.as_mut() | |
| && matches!(turn_item, TurnItem::AgentMessage(_)) | |
| { | |
| let item_id = turn_item.id(); | |
| state | |
| .pending_agent_message_items | |
| .insert(item_id, turn_item.clone()); | |
| } else { | |
| sess.emit_turn_item_started(&turn_context, &turn_item).await; | |
| } | |
| if let (Some(state), Some(item_id), Some(parsed)) = ( | |
| plan_mode_state.as_mut(), | |
| seeded_item_id.as_deref(), | |
| seeded_parsed, | |
| ) { | |
| emit_streamed_assistant_text_delta( | |
| &sess, | |
| &turn_context, | |
| Some(state), | |
| item_id, | |
| parsed, | |
| ) | |
| .await; | |
| } | |
| } | |
| active_item = Some(turn_item); | |
| active_item_is_streaming_to_client = stream_item_to_client; | |
| } | |
| } | |
| ResponseEvent::ServerModel(server_model) => { | |
| if !turn_context | |
| .server_model_warning_emitted | |
| .load(Ordering::Relaxed) | |
| && sess | |
| .maybe_warn_on_server_model_mismatch(&step_context, server_model) | |
| .await | |
| { | |
| turn_context | |
| .server_model_warning_emitted | |
| .store(true, Ordering::Relaxed); | |
| } | |
| } | |
| ResponseEvent::ModelVerifications(verifications) => { | |
| if !turn_context | |
| .model_verification_emitted | |
| .swap(true, Ordering::Relaxed) | |
| { | |
| sess.emit_model_verification(&turn_context, verifications) | |
| .await; | |
| } | |
| } | |
| ResponseEvent::TurnModerationMetadata(metadata) => { | |
| sess.emit_turn_moderation_metadata(&turn_context, metadata) | |
| .await; | |
| } | |
| ResponseEvent::SafetyBuffering(buffering) => { | |
| sess.send_event( | |
| &turn_context, | |
| EventMsg::SafetyBuffering(SafetyBufferingEvent { | |
| model: step_context.settings.model_info.slug.clone(), | |
| use_cases: buffering.use_cases, | |
| reasons: buffering.reasons, | |
| show_buffering_ui: buffering.show_buffering_ui, | |
| faster_model: buffering.faster_model, | |
| }), | |
| ) | |
| .await; | |
| } | |
| ResponseEvent::ServerReasoningIncluded(included) => { | |
| sess.set_server_reasoning_included(included).await; | |
| } | |
| ResponseEvent::RateLimits(snapshot) => { | |
| // Update internal state with latest rate limits, but defer sending until | |
| // token usage is available to avoid duplicate TokenCount events. | |
| sess.record_rate_limits_info(snapshot).await; | |
| should_emit_token_count = true; | |
| } | |
| ResponseEvent::ModelsEtag(etag) => { | |
| // Update internal state with latest models etag | |
| sess.services | |
| .models_manager | |
| .refresh_if_new_etag(etag, turn_context.config.http_client_factory()) | |
| .await; | |
| } | |
| ResponseEvent::Completed { | |
| response_id, | |
| token_usage, | |
| usage_metadata, | |
| end_turn, | |
| } => { | |
| sess.services | |
| .analytics_events_client | |
| .track_code_mode_tool_call( | |
| codex_analytics::CodeModeToolCallFact::SamplingResponseCompleted { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| response_id: response_id.clone(), | |
| tool_call_ids: std::mem::take(&mut analytics_tool_call_ids), | |
| }, | |
| ); | |
| flush_assistant_text_segments_all( | |
| &sess, | |
| &turn_context, | |
| plan_mode_state.as_mut(), | |
| &mut assistant_message_stream_parsers, | |
| ) | |
| .await; | |
| sess.record_observed_response_completed( | |
| &turn_context, | |
| &response_id, | |
| token_usage.as_ref(), | |
| usage_metadata.as_ref(), | |
| ) | |
| .await; | |
| let budget_result = sess | |
| .record_token_usage_info( | |
| &turn_context, | |
| &step_context.settings, | |
| token_usage.as_ref(), | |
| ) | |
| .await; | |
| should_emit_token_count = true; | |
| should_emit_turn_diff = true; | |
| if let Err(err) = budget_result { | |
| break Err(err); | |
| } | |
| if let Some(false) = end_turn { | |
| needs_follow_up = true; | |
| } | |
| break Ok(SamplingRequestResult { | |
| needs_follow_up, | |
| last_agent_message, | |
| }); | |
| } | |
| ResponseEvent::OutputTextDelta(delta) => { | |
| // In review child threads, suppress assistant text deltas; the | |
| // UI will show a selection popup from the final ReviewOutput. | |
| if let Some(active) = active_item.as_ref() { | |
| if !active_item_is_streaming_to_client { | |
| continue; | |
| } | |
| let item_id = active.id(); | |
| if matches!(active, TurnItem::AgentMessage(_)) { | |
| let parsed = assistant_message_stream_parsers.parse_delta(&item_id, &delta); | |
| emit_streamed_assistant_text_delta( | |
| &sess, | |
| &turn_context, | |
| plan_mode_state.as_mut(), | |
| &item_id, | |
| parsed, | |
| ) | |
| .await; | |
| } else { | |
| let event = AgentMessageContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id, | |
| delta, | |
| }; | |
| sess.send_event(&turn_context, EventMsg::AgentMessageContentDelta(event)) | |
| .await; | |
| } | |
| } else { | |
| error_or_panic("OutputTextDelta without active item".to_string()); | |
| } | |
| } | |
| ResponseEvent::ToolCallInputDelta { | |
| item_id: _, | |
| call_id, | |
| delta, | |
| } => { | |
| let Some((active_call_id, consumer)) = active_tool_argument_diff_consumer.as_mut() | |
| else { | |
| continue; | |
| }; | |
| let call_id = match call_id { | |
| Some(call_id) if call_id.as_str() != active_call_id.as_str() => continue, | |
| Some(call_id) => call_id, | |
| None => active_call_id.clone(), | |
| }; | |
| if let Some(event) = consumer.consume_diff(turn_context.as_ref(), call_id, &delta) { | |
| sess.send_event(&turn_context, event).await; | |
| } | |
| } | |
| ResponseEvent::ReasoningSummaryDelta { | |
| delta, | |
| summary_index, | |
| } => { | |
| if uses_sequential_cutoff_reasoning_summaries { | |
| continue; | |
| } | |
| if let Some(active) = active_item.as_ref() { | |
| if !active_item_is_streaming_to_client { | |
| continue; | |
| } | |
| let event = ReasoningContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id: active.id(), | |
| delta, | |
| summary_index, | |
| }; | |
| sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) | |
| .await; | |
| } else { | |
| error_or_panic("ReasoningSummaryDelta without active item".to_string()); | |
| } | |
| } | |
| ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { | |
| if uses_sequential_cutoff_reasoning_summaries { | |
| continue; | |
| } | |
| if let Some(active) = active_item.as_ref() { | |
| if !active_item_is_streaming_to_client { | |
| continue; | |
| } | |
| let event = | |
| EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { | |
| item_id: active.id(), | |
| summary_index, | |
| }); | |
| sess.send_event(&turn_context, event).await; | |
| } else { | |
| error_or_panic("ReasoningSummaryPartAdded without active item".to_string()); | |
| } | |
| } | |
| ResponseEvent::ReasoningSummaryDone { | |
| item_id, | |
| text, | |
| summary_index, | |
| } => { | |
| if !uses_sequential_cutoff_reasoning_summaries { | |
| continue; | |
| } | |
| let Some(active) = active_item.as_ref() else { | |
| continue; | |
| }; | |
| if !active_item_is_streaming_to_client || active.id() != item_id { | |
| continue; | |
| } | |
| if summary_index > 0 { | |
| sess.send_event( | |
| &turn_context, | |
| EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { | |
| item_id: item_id.clone(), | |
| summary_index, | |
| }), | |
| ) | |
| .await; | |
| } | |
| let event = ReasoningContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id, | |
| delta: text, | |
| summary_index, | |
| }; | |
| sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) | |
| .await; | |
| } | |
| ResponseEvent::ReasoningContentDelta { | |
| delta, | |
| content_index, | |
| } => { | |
| if let Some(active) = active_item.as_ref() { | |
| if !active_item_is_streaming_to_client { | |
| continue; | |
| } | |
| let event = ReasoningRawContentDeltaEvent { | |
| thread_id: sess.thread_id.to_string(), | |
| turn_id: turn_context.sub_id.clone(), | |
| item_id: active.id(), | |
| delta, | |
| content_index, | |
| }; | |
| sess.send_event(&turn_context, EventMsg::ReasoningRawContentDelta(event)) | |
| .await; | |
| } else { | |
| error_or_panic("ReasoningRawContentDelta without active item".to_string()); | |
| } | |
| } | |
| } | |
| }; | |
| drop(sampling_timing_guard); | |
| flush_assistant_text_segments_all( | |
| &sess, | |
| &turn_context, | |
| plan_mode_state.as_mut(), | |
| &mut assistant_message_stream_parsers, | |
| ) | |
| .await; | |
| let tool_blocking_timing_guard = if in_flight.is_empty() { | |
| None | |
| } else { | |
| Some(turn_context.turn_timing_state.begin_tool_blocking()) | |
| }; | |
| drain_in_flight(&mut in_flight, sess.clone(), &step_context).await?; | |
| drop(tool_blocking_timing_guard); | |
| if should_emit_token_count { | |
| // A tool call such as request_user_input can intentionally pause the turn. Emit token | |
| // counts only after pending tools resolve so clients do not see progress events while the | |
| // turn is waiting on the user. This also needs to happen before returning cancellation so | |
| // token usage already recorded from the completed response is still persisted. | |
| sess.send_token_count_event(&turn_context).await; | |
| } | |
| if cancellation_token.is_cancelled() { | |
| return Err(CodexErr::TurnAborted); | |
| } | |
| if should_emit_turn_diff { | |
| let unified_diff = { | |
| let tracker = turn_diff_tracker.lock().await; | |
| tracker.get_unified_diff() | |
| }; | |
| if let Some(unified_diff) = unified_diff { | |
| let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); | |
| sess.clone().send_event(&turn_context, msg).await; | |
| } | |
| } | |
| outcome | |
| } | |
| pub(crate) fn get_last_assistant_message_from_turn<'a>( | |
| responses: impl DoubleEndedIterator<Item = &'a ResponseItem>, | |
| ) -> Option<String> { | |
| for item in responses.rev() { | |
| if let Some(message) = last_assistant_message_from_item(item, /*plan_mode*/ false) { | |
| return Some(message); | |
| } | |
| } | |
| None | |
| } | |
| mod tests; | |