mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## Why When `codex-auto-review` is missing from the model catalog, Guardian falls back to the parent model. Refresh the catalog before accepting that fallback so reviews and checkpoint compatibility checks can use the default reviewer when it becomes available. ## What changed - Share one refresh attempt per parent session, preserving it across cancelled waiters and retaining the parent fallback if recovery fails. - Respect explicit reviewer overrides and custom catalogs, and skip recovery for basic sessions. - Resolve reviewers from the in-memory catalog so a failed disk cache write does not discard a recovered reviewer. - Apply review deadlines and cancellation to configuration setup, and avoid reviewer resolution during history replay unless checkpoint compatibility requires it. ## Testing Add coverage for recovery, failed fetches and cache writes, overrides and custom catalogs, checkpoint compatibility, cancelled prewarming, review deadlines, and interruption. GitOrigin-RevId: 87ae285713afde518a12bc2daba006a043b13bc5
5757 lines
229 KiB
Rust
5757 lines
229 KiB
Rust
pub(crate) mod startup;
|
|
|
|
use crate::PromptCacheKey;
|
|
use crate::WithTurnExtensionData;
|
|
use std::borrow::Cow;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::fmt::Debug;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::time::SystemTime;
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
use crate::agent::AgentStatus;
|
|
use crate::agent::agent_status_from_event;
|
|
use crate::agent::api::AgentConfigUpdate;
|
|
use crate::agent::api::AgentTurnOutcome;
|
|
use crate::agent::control::AgentControlInit;
|
|
use crate::agent::status::is_final;
|
|
use crate::agents_md_manager::SessionInstructions;
|
|
use crate::attestation::AttestationProvider;
|
|
use crate::compact;
|
|
use crate::compact::CompactedHistoryMetadata;
|
|
use crate::config::ManagedFeatures;
|
|
use crate::context::ContextualUserFragment;
|
|
use crate::context::DeveloperInstructions;
|
|
use crate::context::GuardianPolicy;
|
|
use crate::context::ManagedDeveloperInstructions;
|
|
use crate::context::ModelSwitchInstructions;
|
|
use crate::context::MultiAgentRoleInstructions;
|
|
use crate::context::NetworkRuleSaved;
|
|
use crate::context::RecommendedPluginsInstructions;
|
|
use crate::context::world_state::Placement;
|
|
use crate::context::world_state::WorldState;
|
|
use crate::context::world_state::WorldStateSnapshot;
|
|
use crate::context::world_state::WorldStateUpdate;
|
|
use crate::context::world_state::WorldStateUpdateContent;
|
|
use crate::context::world_state::split_prefix_updates;
|
|
use crate::current_time::TimeProvider;
|
|
use crate::environment_selection::TurnEnvironmentSnapshot;
|
|
use crate::exec_policy::BANNED_PREFIX_SUGGESTIONS;
|
|
use crate::exec_policy::ExecPolicyManager;
|
|
use crate::exec_policy::default_policy_path;
|
|
use crate::image_preparation::ImagePreparationMode;
|
|
use crate::image_preparation::ImageResizeNoticeMode;
|
|
use crate::image_preparation::prepare_response_items as prepare_image_response_items;
|
|
use crate::image_preparation::unified_image_budget_enabled;
|
|
use crate::parse_turn_item;
|
|
use crate::realtime_conversation::RealtimeConversationManager;
|
|
use crate::realtime_history::RealtimeEventOrder;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::step_settings::ResolvedStepSettings;
|
|
use crate::session::step_settings::StepSettings;
|
|
use crate::session::turn_context::TurnEnvironment;
|
|
use crate::shell_snapshot::SnapshotCredentialBrokerState;
|
|
use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context;
|
|
use crate::turn_metadata::TurnMetadataState;
|
|
use crate::turn_timing::now_unix_timestamp_ms;
|
|
use async_channel::Receiver;
|
|
use async_channel::Sender;
|
|
use chrono::DateTime;
|
|
use chrono::Local;
|
|
use chrono::Utc;
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
use codex_analytics::ImagePreparationFact;
|
|
use codex_analytics::ImagePreparationMetadata;
|
|
use codex_analytics::SubAgentThreadStartedInput;
|
|
use codex_analytics::ThreadInitializationMode;
|
|
use codex_analytics::TurnCodexErrorFact;
|
|
use codex_async_utils::OrCancelExt;
|
|
use codex_attachment_store::AttachmentStore;
|
|
use codex_attachment_store::InlineAttachmentStore;
|
|
use codex_connectors::connector_runtime_context_key;
|
|
use codex_context_fragments::RenderedFragment;
|
|
use codex_exec_server::Environment;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_execpolicy::prefix_rule_migration;
|
|
use codex_extension_api::ConversationHistorySnapshot;
|
|
use codex_extension_api::ExtensionDataInit;
|
|
use codex_extension_api::PromptSlot;
|
|
use codex_extension_api::TurnContextContributionInput;
|
|
use codex_features::FEATURES;
|
|
use codex_features::Feature;
|
|
use codex_features::Features;
|
|
use codex_features::unstable_features_warning_event;
|
|
use codex_history::HistoryInitialization;
|
|
use codex_history::RolloutItem;
|
|
use codex_hooks::Hooks;
|
|
use codex_hooks::HooksConfig;
|
|
use codex_login::AuthManager;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
|
use codex_mcp::McpResourceClient;
|
|
use codex_mcp::McpRuntime;
|
|
use codex_mcp::McpRuntimeContext;
|
|
use codex_mcp::McpRuntimeInput;
|
|
use codex_models_manager::manager::RefreshStrategy;
|
|
use codex_models_manager::manager::SharedModelsManager;
|
|
use codex_network_proxy::NetworkProxy;
|
|
use codex_network_proxy::NetworkProxyAuditMetadata;
|
|
use codex_network_proxy::normalize_host;
|
|
use codex_otel::auth_storage::AuthStorageOriginator;
|
|
use codex_otel::current_span_trace_id;
|
|
use codex_otel::current_span_w3c_trace_context;
|
|
use codex_otel::set_parent_from_w3c_trace_context;
|
|
use codex_prompts::ResolvedModelMessages;
|
|
use codex_prompts::render_model_instructions;
|
|
use codex_protocol::ResponseUsageMetadata;
|
|
use codex_protocol::SessionId;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::ElicitationRequest;
|
|
use codex_protocol::approvals::ElicitationRequestEvent;
|
|
use codex_protocol::approvals::ExecPolicyAmendment;
|
|
use codex_protocol::approvals::NetworkPolicyAmendment;
|
|
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
|
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
|
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
|
use codex_protocol::items::EnteredReviewModeItem;
|
|
use codex_protocol::items::ModelInvocationContext;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::AdditionalPermissionProfile;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::models::BaseInstructionsProvenance;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ContentItemKind;
|
|
use codex_protocol::models::ContentItemMetadata;
|
|
use codex_protocol::models::ContentItemNamespace;
|
|
use codex_protocol::models::ImageReference;
|
|
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::SandboxEnforcement;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::FileChange;
|
|
use codex_protocol::protocol::HasLegacyEvent;
|
|
use codex_protocol::protocol::HistoryPosition;
|
|
use codex_protocol::protocol::ItemCompletedEvent;
|
|
use codex_protocol::protocol::ItemStartedEvent;
|
|
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
|
use codex_protocol::protocol::MultiAgentVersion;
|
|
use codex_protocol::protocol::PrivateSessionEvent;
|
|
use codex_protocol::protocol::PrivateSessionEventPayload;
|
|
use codex_protocol::protocol::RawResponseItemEvent;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadHistoryMode;
|
|
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::TurnEnvironmentRequest;
|
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::W3cTraceContext;
|
|
use codex_protocol::protocol::WorldStateItem;
|
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
|
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputArgs;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_rmcp_client::ElicitationResponse;
|
|
use codex_rollout::should_persist_response_item;
|
|
use codex_rollout::state_db;
|
|
use codex_rollout_trace::ThreadStartedTraceMetadata;
|
|
use codex_rollout_trace::ThreadTraceContext;
|
|
use codex_sandboxing::SandboxType;
|
|
use codex_sandboxing::policy_transforms::intersect_permission_profiles_with_context;
|
|
use codex_shell_command::parse_command::parse_command;
|
|
use codex_terminal_detection::user_agent;
|
|
use codex_thread_store::CreateThreadParams;
|
|
use codex_thread_store::LiveThread;
|
|
use codex_thread_store::LiveThreadInitGuard;
|
|
use codex_thread_store::LocalThreadStore;
|
|
use codex_thread_store::PersistContext;
|
|
use codex_thread_store::ReadThreadParams;
|
|
use codex_thread_store::ResumeThreadParams;
|
|
use codex_thread_store::ThreadPersistenceMetadata;
|
|
use codex_thread_store::ThreadStore;
|
|
use codex_thread_store::ThreadStoreResumePreparation;
|
|
use codex_utils_audio::prepare_response_items as prepare_audio_response_items;
|
|
use codex_utils_git_discovery::GitRootDiscovery;
|
|
use codex_utils_output_truncation::with_serialization_allowance;
|
|
use codex_utils_path_uri::PathUri;
|
|
use futures::future::BoxFuture;
|
|
use futures::future::Shared;
|
|
use futures::prelude::*;
|
|
use rmcp::model::RequestId;
|
|
use serde_json::Value;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::RwLock;
|
|
use tokio::sync::oneshot;
|
|
use tokio::sync::watch;
|
|
use tokio::task::JoinHandle;
|
|
use tokio_util::sync::CancellationToken;
|
|
use toml::Value as TomlValue;
|
|
use tracing::Instrument;
|
|
use tracing::debug;
|
|
use tracing::error;
|
|
use tracing::info;
|
|
use tracing::info_span;
|
|
use tracing::instrument;
|
|
use tracing::warn;
|
|
use uuid::Uuid;
|
|
|
|
use crate::client::ModelClient;
|
|
use crate::codex_thread::CodexThreadSettingsOverrides;
|
|
use crate::codex_thread::ThreadConfigSnapshot;
|
|
#[cfg(test)]
|
|
use crate::compact::collect_user_messages;
|
|
use crate::config::Config;
|
|
use crate::config::ConstraintResult;
|
|
use crate::config::PermissionProfileSnapshot;
|
|
use crate::config::PermissionProfileState;
|
|
use crate::config::StartedNetworkProxy;
|
|
use crate::config::resolve_web_search_mode_for_turn;
|
|
use crate::context_manager::ContextManager;
|
|
use crate::context_manager::HistoryReplacement;
|
|
use crate::thread_rollout_truncation::initial_history_has_prior_user_turns;
|
|
use codex_config::CONFIG_TOML_FILE;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::types::McpServerConfig;
|
|
use codex_model_provider::create_model_provider;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::CodexErrorDetails;
|
|
use codex_protocol::error::ResponseIncompleteError;
|
|
use codex_protocol::error::Result as CodexResult;
|
|
#[cfg(test)]
|
|
use codex_protocol::exec_output::StreamOutput;
|
|
|
|
pub(crate) mod async_compaction;
|
|
mod code_mode_warning;
|
|
mod config_refresh;
|
|
pub(crate) mod context_window;
|
|
mod daemon_recovery;
|
|
mod environment;
|
|
mod extension_interruption;
|
|
pub(crate) mod extension_metrics;
|
|
mod guardian_checkpoint;
|
|
mod handlers;
|
|
mod inject;
|
|
mod reasoning_effort;
|
|
mod submission;
|
|
pub(crate) use reasoning_effort::RequestEffortUsage;
|
|
pub(crate) use submission::Submission;
|
|
mod input_queue;
|
|
mod mcp;
|
|
mod mcp_prewarm;
|
|
mod mcp_refresh;
|
|
mod mcp_runtime;
|
|
pub(crate) mod multi_agents;
|
|
mod plugin_selection;
|
|
mod realtime_history;
|
|
mod retained_context;
|
|
mod review;
|
|
mod rollout_budget;
|
|
mod rollout_reconstruction;
|
|
#[allow(clippy::module_inception)]
|
|
pub(crate) mod session;
|
|
pub(crate) mod startup_prewarm;
|
|
mod step_activation;
|
|
pub(crate) mod step_context;
|
|
pub(crate) mod step_settings;
|
|
mod thread_settings;
|
|
pub(crate) mod time_reminder;
|
|
mod token_budget;
|
|
pub(crate) mod turn;
|
|
pub(crate) mod turn_context;
|
|
mod turn_input;
|
|
mod turn_suspension;
|
|
mod world_state;
|
|
use self::code_mode_warning::unsupported_code_mode_warning;
|
|
pub(crate) use self::environment::ThreadEnvironmentDefaults;
|
|
#[cfg(test)]
|
|
use self::handlers::submission_dispatch_span;
|
|
use self::handlers::submission_loop;
|
|
pub(crate) use self::input_queue::InputQueueActivity;
|
|
pub(crate) use self::input_queue::TurnInput;
|
|
pub(crate) use self::input_queue::TurnInputQueue;
|
|
pub(crate) use self::input_queue::UserInputMetadata;
|
|
use self::review::spawn_review_thread;
|
|
use self::session::AppServerClientMetadata;
|
|
use self::session::Session;
|
|
use self::session::SessionConfiguration;
|
|
use self::session::SessionSettingsCommit;
|
|
pub(crate) use self::session::SessionSettingsUpdate;
|
|
#[cfg(test)]
|
|
use self::turn::AssistantMessageStreamParsers;
|
|
use self::turn::RealtimeEventText;
|
|
use self::turn::agent_message_text;
|
|
#[cfg(test)]
|
|
use self::turn::collect_explicit_app_ids_from_skill_items;
|
|
use self::turn::realtime_text_for_event;
|
|
use self::turn_context::TurnContext;
|
|
#[cfg(test)]
|
|
mod rollout_reconstruction_tests;
|
|
|
|
use crate::exec_policy::ExecPolicyUpdateError;
|
|
use crate::guardian::GuardianReviewSessionManager;
|
|
use crate::mcp::McpEnvironmentScope;
|
|
use crate::mcp::McpManager;
|
|
use crate::mcp::McpThreadIdentity;
|
|
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
|
use crate::rollout::map_session_init_error;
|
|
use crate::session::startup_prewarm::SessionStartupPrewarmHandle;
|
|
use crate::shell;
|
|
use crate::state::AcceptedUserInputResponse;
|
|
use crate::state::AutoCompactWindowIds;
|
|
use crate::state::AutoCompactWindowSnapshot;
|
|
use crate::state::PendingRequestPermissions;
|
|
use crate::state::ReasoningEffortPin;
|
|
use crate::state::SessionServices;
|
|
use crate::state::SessionState;
|
|
use crate::state::TaskKind;
|
|
#[cfg(test)]
|
|
use crate::stream_events_utils::HandleOutputCtx;
|
|
#[cfg(test)]
|
|
use crate::stream_events_utils::handle_output_item_done;
|
|
use crate::tasks::ReviewTask;
|
|
use crate::tools::ApprovalContext;
|
|
use crate::tools::handlers::PreparedPermissionRequest;
|
|
use crate::tools::network_approval::NetworkApprovalService;
|
|
use crate::tools::network_approval::build_blocked_request_observer;
|
|
use crate::tools::network_approval::build_network_policy_decider;
|
|
#[cfg(test)]
|
|
use crate::tools::parallel::ToolCallRuntime;
|
|
use crate::tools::sandboxing::ApprovalStore;
|
|
use crate::turn_timing::TurnTimingState;
|
|
use crate::turn_timing::record_turn_ttfm_metric;
|
|
use crate::unified_exec::UnifiedExecProcessManager;
|
|
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
|
use crate::windows_sandbox::local_binding_policy_for_sandbox;
|
|
use crate::windows_sandbox::managed_proxy_routing_for_windows_sandbox;
|
|
use codex_core_plugins::PluginCommandAttribution;
|
|
use codex_core_plugins::PluginsManager;
|
|
use codex_core_plugins::RecommendedPluginCandidatesInput;
|
|
use codex_git_utils::get_git_repo_root;
|
|
use codex_history::CodexHarnessMetadata;
|
|
use codex_history::CompactedItem;
|
|
use codex_history::CompactionResumeMetadata;
|
|
use codex_history::DynamicToolsSnapshot;
|
|
use codex_history::InitialHistory;
|
|
pub(crate) use codex_history::PreviousTurnSettings;
|
|
use codex_history::ResponseItemEnvelope;
|
|
use codex_mcp::McpConfig;
|
|
use codex_mcp::effective_mcp_servers;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_otel::THREAD_STARTED_METRIC;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ResponseItemId;
|
|
use codex_protocol::approvals::ExecApprovalKind;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::mcp::ClientMcpExtensions;
|
|
use codex_protocol::models::LocalImagePreparation;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::CodexErrorInfo;
|
|
use codex_protocol::protocol::DeprecationNoticeEvent;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
|
use codex_protocol::protocol::ModelRerouteEvent;
|
|
use codex_protocol::protocol::ModelRerouteReason;
|
|
use codex_protocol::protocol::ModelVerification;
|
|
use codex_protocol::protocol::ModelVerificationEvent;
|
|
use codex_protocol::protocol::NetworkApprovalContext;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::RateLimitSnapshot;
|
|
use codex_protocol::protocol::RawResponseCompletedEvent;
|
|
use codex_protocol::protocol::RequestUserInputEvent;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use codex_protocol::protocol::SessionNetworkProxyRuntime;
|
|
use codex_protocol::protocol::StreamErrorEvent;
|
|
use codex_protocol::protocol::ThreadMemoryMode;
|
|
use codex_protocol::protocol::TokenCountEvent;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_protocol::protocol::TokenUsageInfo;
|
|
use codex_protocol::protocol::TokenUsageRecord;
|
|
use codex_protocol::protocol::TurnModerationMetadataEvent;
|
|
use codex_protocol::protocol::WarningEvent;
|
|
use codex_protocol::turn_input::TurnInputMode;
|
|
use codex_protocol::turn_input::TurnInputRequest;
|
|
use codex_protocol::turn_input::TurnInputSubmission;
|
|
use codex_protocol::turn_input::TurnStartOptions;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_skills_extension::HostSkillsService;
|
|
use codex_tools::ToolName;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
#[cfg(test)]
|
|
use codex_utils_stream_parser::ProposedPlanSegment;
|
|
|
|
/// Whether reported usage updates the installed context baseline or only spending.
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) enum TokenUsageContext {
|
|
Sampling,
|
|
SpendingOnly,
|
|
}
|
|
|
|
/// Queue and lifecycle endpoints for a running [`Session`].
|
|
///
|
|
/// Runtime state lives on `Session`; keeping these endpoints separate lets all
|
|
/// submission senders be dropped to terminate the session loop. The shared
|
|
/// completion future observes that shutdown.
|
|
#[derive(Clone)]
|
|
pub(crate) struct SessionIo {
|
|
pub(crate) tx_sub: Sender<Submission>,
|
|
pub(crate) rx_event: Receiver<Event>,
|
|
// Last known status of the agent.
|
|
pub(crate) agent_status: watch::Receiver<AgentStatus>,
|
|
// Shared future for the background submission loop completion so multiple
|
|
// callers can wait for shutdown.
|
|
pub(crate) session_loop_termination: SessionLoopTermination,
|
|
}
|
|
|
|
pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum GitEnrichmentPolicy {
|
|
Fresh,
|
|
Skip,
|
|
}
|
|
|
|
/// Controls which fork history belongs in the newly created thread's own rollout.
|
|
pub(crate) enum ForkPersistence {
|
|
Copied,
|
|
/// The spawn caller owns the durability barrier before acknowledging the child.
|
|
CopiedDeferred,
|
|
Referenced {
|
|
history_base: Option<HistoryPosition>,
|
|
inherited_item_count: usize,
|
|
},
|
|
}
|
|
|
|
/// Normal appends keep this generation; compaction and rollback start a new one.
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) struct CompactionPrefix {
|
|
pub(crate) generation: u64,
|
|
pub(crate) len: usize,
|
|
pub(crate) auth_owner_generation: Option<u64>,
|
|
}
|
|
|
|
pub(crate) enum CompactedHistoryInstallation<'a> {
|
|
Complete(CompactedHistoryMetadata),
|
|
Prefix {
|
|
prefix: CompactionPrefix,
|
|
prefix_context: Vec<ResponseItem>,
|
|
initial_context: Vec<ResponseItemEnvelope>,
|
|
model_info: &'a ModelInfo,
|
|
compaction_response_id: String,
|
|
reviewer_compaction_hash: Option<String>,
|
|
turn_context: &'a TurnContext,
|
|
started_item: Option<&'a TurnItem>,
|
|
},
|
|
}
|
|
|
|
impl From<CompactedHistoryMetadata> for CompactedHistoryInstallation<'_> {
|
|
fn from(metadata: CompactedHistoryMetadata) -> Self {
|
|
Self::Complete(metadata)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct SessionSpawnArgs {
|
|
pub(crate) attached_thread: bool,
|
|
pub(crate) startup: Option<Arc<startup::SessionStartup>>,
|
|
pub(crate) config: Config,
|
|
pub(crate) prompt_cache_key: PromptCacheKey,
|
|
pub(crate) allow_provider_model_fallback: bool,
|
|
pub(crate) instructions: SessionInstructions,
|
|
pub(crate) installation_id: String,
|
|
pub(crate) auth_manager: Arc<AuthManager>,
|
|
pub(crate) models_manager: SharedModelsManager,
|
|
pub(crate) git_root_discovery: Arc<GitRootDiscovery>,
|
|
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
|
pub(crate) skills_service: Arc<HostSkillsService>,
|
|
pub(crate) plugins_manager: Arc<PluginsManager>,
|
|
pub(crate) mcp_manager: Arc<McpManager>,
|
|
pub(crate) code_mode_session_provider: Arc<dyn codex_code_mode::CodeModeSessionProvider>,
|
|
pub(crate) extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
|
|
pub(crate) conversation_history: InitialHistory,
|
|
pub(crate) resume_preparation: Option<ThreadStoreResumePreparation>,
|
|
pub(crate) history_initialization: HistoryInitialization,
|
|
pub(crate) disabled_plugin_ids: Option<Vec<String>>,
|
|
pub(crate) requested_history_mode: Option<ThreadHistoryMode>,
|
|
pub(crate) fork_persistence: ForkPersistence,
|
|
pub(crate) session_source: SessionSource,
|
|
pub(crate) forked_from_thread_id: Option<ThreadId>,
|
|
pub(crate) parent_thread_id: Option<ThreadId>,
|
|
pub(crate) thread_source: Option<ThreadSource>,
|
|
pub(crate) originator: String,
|
|
pub(crate) agent_control: AgentControlInit,
|
|
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
|
pub(crate) metrics_service_name: Option<String>,
|
|
pub(crate) inherited_exec_policy: Option<Arc<ExecPolicyManager>>,
|
|
pub(crate) inherited_environments: Option<TurnEnvironmentSnapshot>,
|
|
/// Parent rollout trace used only to derive fresh spawned child traces.
|
|
///
|
|
/// Root sessions and non-thread-spawn subagents pass a disabled context;
|
|
/// `Session::new` creates the root trace itself when rollout tracing is enabled.
|
|
pub(crate) parent_rollout_thread_trace: ThreadTraceContext,
|
|
pub(crate) user_shell_override: Option<shell::Shell>,
|
|
pub(crate) parent_trace: Option<W3cTraceContext>,
|
|
pub(crate) environment_requests: Vec<TurnEnvironmentRequest>,
|
|
pub(crate) thread_extension_init: ExtensionDataInit,
|
|
pub(crate) turn_extension_init: ExtensionDataInit,
|
|
pub(crate) client_mcp_extensions: ClientMcpExtensions,
|
|
pub(crate) reserved_thread_id: Option<ThreadId>,
|
|
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
|
|
pub(crate) image_store: Arc<dyn AttachmentStore>,
|
|
pub(crate) thread_store: Arc<dyn ThreadStore>,
|
|
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
|
pub(crate) external_time_provider: Option<Arc<dyn TimeProvider>>,
|
|
pub(crate) inherited_multi_agent_version: Option<MultiAgentVersion>,
|
|
pub(crate) git_enrichment_policy: GitEnrichmentPolicy,
|
|
pub(crate) windows_sandbox_proxy_settings_mode:
|
|
codex_sandboxing::WindowsSandboxProxySettingsMode,
|
|
}
|
|
|
|
pub(crate) fn resolve_multi_agent_version(
|
|
conversation_history: &InitialHistory,
|
|
inherited_multi_agent_version: Option<MultiAgentVersion>,
|
|
) -> Option<MultiAgentVersion> {
|
|
if inherited_multi_agent_version == Some(MultiAgentVersion::Disabled) {
|
|
return Some(MultiAgentVersion::Disabled);
|
|
}
|
|
|
|
conversation_history
|
|
.get_multi_agent_version()
|
|
.or(inherited_multi_agent_version)
|
|
.or(match conversation_history {
|
|
InitialHistory::New | InitialHistory::Cleared => None,
|
|
// Threads created before runtime metadata existed keep the legacy V1 tool surface.
|
|
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => Some(MultiAgentVersion::V1),
|
|
})
|
|
}
|
|
|
|
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
|
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 512;
|
|
const CYBER_VERIFY_URL: &str = "https://chatgpt.com/cyber";
|
|
const CYBER_SAFETY_URL: &str = "https://developers.openai.com/codex/concepts/cyber-safety";
|
|
|
|
impl Session {
|
|
/// Spawn and initialize a new session.
|
|
/// Hide the concrete startup future from callers while keeping initialization lazy.
|
|
#[inline(never)]
|
|
pub(crate) fn spawn(
|
|
args: SessionSpawnArgs,
|
|
) -> BoxFuture<'static, CodexResult<(Arc<Self>, SessionIo)>> {
|
|
let tree_shutdown = args.agent_control.runtime().shutdown.clone();
|
|
Box::pin(async move {
|
|
let parent_trace = match args.parent_trace {
|
|
Some(trace) => {
|
|
if codex_otel::context_from_w3c_trace_context(&trace).is_some() {
|
|
Some(trace)
|
|
} else {
|
|
warn!("ignoring invalid thread spawn trace carrier");
|
|
None
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
let thread_spawn_span = info_span!("thread_spawn", otel.name = "thread_spawn");
|
|
if let Some(trace) = parent_trace.as_ref() {
|
|
let _ = set_parent_from_w3c_trace_context(&thread_spawn_span, trace);
|
|
}
|
|
let spawn = Self::spawn_internal(SessionSpawnArgs {
|
|
parent_trace,
|
|
..args
|
|
})
|
|
.instrument(thread_spawn_span);
|
|
tokio::select! {
|
|
biased;
|
|
_ = tree_shutdown.cancelled() => Err(CodexErr::TurnAborted),
|
|
result = spawn => result,
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn spawn_internal(args: SessionSpawnArgs) -> CodexResult<(Arc<Self>, SessionIo)> {
|
|
args.config
|
|
.config_layer_stack
|
|
.ensure_config_server_applied()?;
|
|
args.config.validate_windows_mxc_requirement()?;
|
|
let SessionSpawnArgs {
|
|
attached_thread,
|
|
startup,
|
|
config,
|
|
prompt_cache_key,
|
|
allow_provider_model_fallback,
|
|
instructions,
|
|
installation_id,
|
|
auth_manager,
|
|
models_manager,
|
|
git_root_discovery,
|
|
environment_manager,
|
|
skills_service,
|
|
plugins_manager,
|
|
mcp_manager,
|
|
code_mode_session_provider,
|
|
extensions,
|
|
conversation_history,
|
|
resume_preparation,
|
|
history_initialization,
|
|
disabled_plugin_ids,
|
|
requested_history_mode,
|
|
fork_persistence,
|
|
session_source,
|
|
forked_from_thread_id,
|
|
parent_thread_id,
|
|
thread_source,
|
|
originator,
|
|
agent_control,
|
|
dynamic_tools,
|
|
metrics_service_name,
|
|
user_shell_override,
|
|
inherited_exec_policy,
|
|
inherited_environments,
|
|
parent_rollout_thread_trace,
|
|
parent_trace: _,
|
|
environment_requests,
|
|
mut thread_extension_init,
|
|
turn_extension_init,
|
|
client_mcp_extensions,
|
|
reserved_thread_id,
|
|
analytics_events_client,
|
|
image_store,
|
|
thread_store,
|
|
attestation_provider,
|
|
external_time_provider,
|
|
inherited_multi_agent_version,
|
|
git_enrichment_policy,
|
|
windows_sandbox_proxy_settings_mode,
|
|
} = args;
|
|
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
|
let (tx_event, rx_event) = async_channel::unbounded();
|
|
|
|
let isolation = thread_extension_init
|
|
.get::<codex_extension_api::SessionIsolation>()
|
|
.map(|policy| *policy)
|
|
.unwrap_or_default();
|
|
// Manager-owned threads and inline delegates bind roots at the same startup boundary.
|
|
let selected_capability_roots =
|
|
match thread_extension_init.get::<Vec<SelectedCapabilityRoot>>() {
|
|
Some(roots) => roots.as_ref().clone(),
|
|
None => {
|
|
// Resume restores this thread's saved roots, including roots for environments
|
|
// it will select again later. New children inherit roots regardless of copied history.
|
|
let roots = if !matches!(&conversation_history, InitialHistory::Resumed(_))
|
|
&& isolation == codex_extension_api::SessionIsolation::Inherit
|
|
&& let Some(environments) = &inherited_environments
|
|
{
|
|
environments.selected_capability_roots()
|
|
} else {
|
|
conversation_history.get_selected_capability_roots()
|
|
};
|
|
if !roots.is_empty() {
|
|
thread_extension_init.insert(roots.clone());
|
|
}
|
|
roots
|
|
}
|
|
};
|
|
let environment_selections = environment_requests
|
|
.into_iter()
|
|
.map(|request| TurnEnvironmentSelection::new(request, &selected_capability_roots))
|
|
.collect::<Vec<_>>();
|
|
// Enforce snapshot-only instructions for both managed and inline isolated sessions.
|
|
let instructions = if isolation == codex_extension_api::SessionIsolation::Isolated {
|
|
SessionInstructions {
|
|
user: instructions.user,
|
|
thread: instructions.thread,
|
|
project_snapshot: instructions.project_snapshot,
|
|
..Default::default()
|
|
}
|
|
} else {
|
|
instructions
|
|
};
|
|
let exec_policy = if isolation == codex_extension_api::SessionIsolation::Isolated {
|
|
let managed_policy = config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.exec_policy
|
|
.as_deref()
|
|
.map_or_else(codex_execpolicy::Policy::empty, |policy| {
|
|
policy.as_ref().clone()
|
|
});
|
|
Arc::new(ExecPolicyManager::new(Arc::new(managed_policy)))
|
|
} else if let Some(exec_policy) = &inherited_exec_policy {
|
|
Arc::clone(exec_policy)
|
|
} else {
|
|
if !config
|
|
.config_layer_stack
|
|
.ignore_user_and_project_exec_policy_rules()
|
|
{
|
|
let codex_home = config.codex_home.clone();
|
|
let policy_path = default_policy_path(codex_home.as_path());
|
|
if let Err(err) = prefix_rule_migration(
|
|
codex_home.as_path(),
|
|
policy_path.as_path(),
|
|
BANNED_PREFIX_SUGGESTIONS,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %err, "failed to run prefix rule migration");
|
|
}
|
|
}
|
|
Arc::new(
|
|
ExecPolicyManager::load(&config.config_layer_stack)
|
|
.await
|
|
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?,
|
|
)
|
|
};
|
|
|
|
let mut config = Arc::new(config);
|
|
let refresh_strategy = if session_source.is_non_root_agent() {
|
|
codex_models_manager::manager::RefreshStrategy::Offline
|
|
} else {
|
|
codex_models_manager::manager::RefreshStrategy::OnlineIfUncached
|
|
};
|
|
if config.model.is_none()
|
|
|| !matches!(
|
|
refresh_strategy,
|
|
codex_models_manager::manager::RefreshStrategy::Offline
|
|
)
|
|
{
|
|
let _ = models_manager
|
|
.list_models(refresh_strategy, config.http_client_factory())
|
|
.await;
|
|
}
|
|
let model = models_manager
|
|
.get_default_model(
|
|
&config.model,
|
|
allow_provider_model_fallback,
|
|
refresh_strategy,
|
|
config.http_client_factory(),
|
|
)
|
|
.await;
|
|
if model.trim().is_empty() {
|
|
return Err(CodexErr::InvalidRequest(
|
|
"No models are available. Set `model` explicitly or check your model catalog configuration.".to_string(),
|
|
));
|
|
}
|
|
let trusted_guardian_reviewer = crate::guardian::is_basic_session_source(&session_source)
|
|
&& !matches!(conversation_history, InitialHistory::Resumed(_));
|
|
if config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.auto_review_required_for_model(&model)
|
|
&& !trusted_guardian_reviewer
|
|
{
|
|
let config = Arc::make_mut(&mut config);
|
|
if matches!(
|
|
config.legacy_sandbox_policy(),
|
|
SandboxPolicy::DangerFullAccess
|
|
) {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile.clone())
|
|
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
|
if let Some(network) = config.permissions.network.as_ref() {
|
|
config.permissions.network = Some(
|
|
network
|
|
.recompute_for_permission_profile(&permission_profile)
|
|
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?,
|
|
);
|
|
}
|
|
}
|
|
config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.approvals_reviewer
|
|
.can_set(&ApprovalsReviewer::AutoReview)
|
|
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
}
|
|
if allow_provider_model_fallback
|
|
&& let Some(requested_model) = config.model.as_ref()
|
|
&& model != *requested_model
|
|
{
|
|
info!(
|
|
model_provider = %config.model_provider_id,
|
|
requested_model,
|
|
fallback_model = %model,
|
|
"replaced unavailable requested model with provider default"
|
|
);
|
|
}
|
|
|
|
// Resolve base instructions for the session. Priority order:
|
|
// 1. config.base_instructions override
|
|
// 2. conversation history => session_meta.base_instructions
|
|
// 3. rendered instructions_template for current model
|
|
let model_info = models_manager
|
|
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
|
.await;
|
|
let auth = auth_manager.auth_cached();
|
|
// Forked subagents keep their parent's activation with the copied history.
|
|
// Fresh children restore configured preferences before applying startup defaults.
|
|
let inherits_token_budget = matches!(&conversation_history, InitialHistory::Forked(_))
|
|
&& config.token_budget_startup_config.is_some();
|
|
if !inherits_token_budget {
|
|
Arc::make_mut(&mut config)
|
|
.prepare_token_budget_for_startup()
|
|
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
|
// Resolve activation for this runtime, including when resuming saved history.
|
|
token_budget::apply_experimental_context(
|
|
Arc::make_mut(&mut config),
|
|
auth.as_ref(),
|
|
&model_info,
|
|
)?;
|
|
token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info);
|
|
}
|
|
let configured_config = Arc::clone(&config);
|
|
let multi_agent_version = config.multi_agent_version_override().or_else(|| {
|
|
resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version)
|
|
});
|
|
let history_mode = conversation_history.get_history_mode(
|
|
requested_history_mode.unwrap_or_else(|| thread_store.default_history_mode()),
|
|
);
|
|
let base_instructions = config
|
|
.base_instructions
|
|
.clone()
|
|
.or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
|
|
.unwrap_or_else(|| render_model_instructions(&model_info));
|
|
|
|
// A fork records its initial catalog in the new thread's creation metadata.
|
|
let dynamic_tools = if dynamic_tools.is_empty()
|
|
&& matches!(conversation_history, InitialHistory::Forked(_))
|
|
{
|
|
conversation_history.get_dynamic_tools().unwrap_or_default()
|
|
} else {
|
|
dynamic_tools
|
|
};
|
|
let disabled_plugin_ids = disabled_plugin_ids.unwrap_or_else(|| {
|
|
let settings_owner = match &conversation_history {
|
|
InitialHistory::Resumed(resumed) => Some(resumed.conversation_id),
|
|
InitialHistory::Forked(_) => forked_from_thread_id,
|
|
InitialHistory::New | InitialHistory::Cleared => None,
|
|
};
|
|
settings_owner
|
|
.and_then(|thread_id| {
|
|
codex_history::latest_disabled_plugin_ids(
|
|
conversation_history.get_rollout_items(),
|
|
thread_id,
|
|
)
|
|
})
|
|
.map(<[String]>::to_vec)
|
|
.unwrap_or_default()
|
|
});
|
|
// TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode
|
|
// to avoid extracting these fields separately and constructing CollaborationMode here.
|
|
let persisted_collaboration_mode = match &conversation_history {
|
|
InitialHistory::Resumed(resumed) => resumed
|
|
.history
|
|
.iter()
|
|
.rev()
|
|
.find_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
|
if event.thread_id == Some(resumed.conversation_id) =>
|
|
{
|
|
Some(&event.thread_settings.collaboration_mode)
|
|
}
|
|
_ => None,
|
|
})
|
|
.or_else(|| {
|
|
resumed
|
|
.history
|
|
.iter()
|
|
.rev()
|
|
.find_map(|item| match item {
|
|
RolloutItem::TurnContext(context) => {
|
|
Some(context.collaboration_mode.as_ref())
|
|
}
|
|
_ => None,
|
|
})
|
|
.flatten()
|
|
}),
|
|
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
|
|
};
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: model.clone(),
|
|
reasoning_effort: config.model_reasoning_effort.clone(),
|
|
developer_instructions: None,
|
|
},
|
|
};
|
|
let collaboration_mode = persisted_collaboration_mode.map_or(collaboration_mode, |saved| {
|
|
saved.with_updates(
|
|
Some(model.clone()),
|
|
Some(config.model_reasoning_effort.clone()),
|
|
/*developer_instructions*/ None,
|
|
)
|
|
});
|
|
let initial_service_tier_warning = unsupported_service_tier_warning(
|
|
config.service_tier.as_deref(),
|
|
&config.features,
|
|
&model_info,
|
|
);
|
|
let storage_originator = AuthStorageOriginator::from_client_name(&originator);
|
|
let session_configuration = SessionConfiguration {
|
|
turn_extension_init,
|
|
provider: create_model_provider(
|
|
config.model_provider.clone(),
|
|
Some(Arc::clone(&auth_manager)),
|
|
),
|
|
step_settings: Arc::new(StepSettings {
|
|
collaboration_mode,
|
|
reasoning_summary: config.model_reasoning_summary,
|
|
service_tier: config.service_tier.clone(),
|
|
personality: config.personality,
|
|
approval_policy: config.permissions.approval_policy.clone(),
|
|
approvals_reviewer: config.approvals_reviewer,
|
|
}),
|
|
environments: environment_selections.clone(),
|
|
model_info_overrides: config.to_models_manager_config().into(),
|
|
developer_instructions: config.developer_instructions.clone(),
|
|
base_instructions,
|
|
permission_profile_state: session_permission_profile_state_from_config(&config)?,
|
|
allow_login_shell: config.permissions.allow_login_shell,
|
|
shell_environment_policy: config.permissions.shell_environment_policy.clone(),
|
|
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
|
windows_sandbox_type: config.effective_local_windows_sandbox_type(),
|
|
use_legacy_landlock: config.features.use_legacy_landlock(),
|
|
legacy_fallback_cwd: config.cwd.clone(),
|
|
runtime_workspace_roots: config.workspace_roots.clone(),
|
|
codex_home: config.codex_home.clone(),
|
|
thread_name: None,
|
|
disabled_plugin_ids,
|
|
original_config_do_not_use: Arc::clone(&config),
|
|
metrics_service_name,
|
|
app_server_client_name: None,
|
|
app_server_client_version: None,
|
|
trusted_guardian_reviewer,
|
|
session_source,
|
|
history_mode,
|
|
forked_from_thread_id,
|
|
parent_thread_id,
|
|
thread_source,
|
|
originator,
|
|
dynamic_tools: Arc::from(dynamic_tools),
|
|
user_shell_override,
|
|
};
|
|
session_configuration
|
|
.validate(&environment_selections)
|
|
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
|
crate::windows_sandbox::log_windows_sandbox_startup(&config);
|
|
|
|
// Generate a unique ID for the lifetime of this session.
|
|
let session_source_clone = session_configuration.session_source.clone();
|
|
let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
|
|
|
let session = Box::pin(Session::new(
|
|
startup.clone(),
|
|
session_configuration,
|
|
attached_thread,
|
|
prompt_cache_key,
|
|
&environment_selections,
|
|
config.clone(),
|
|
instructions,
|
|
installation_id,
|
|
auth_manager.clone(),
|
|
models_manager.clone(),
|
|
git_root_discovery,
|
|
model_info,
|
|
exec_policy,
|
|
tx_event.clone(),
|
|
agent_status_tx.clone(),
|
|
conversation_history,
|
|
history_initialization,
|
|
fork_persistence,
|
|
session_source_clone,
|
|
skills_service,
|
|
plugins_manager,
|
|
mcp_manager.clone(),
|
|
code_mode_session_provider,
|
|
extensions,
|
|
thread_extension_init,
|
|
client_mcp_extensions,
|
|
agent_control,
|
|
reserved_thread_id,
|
|
environment_manager,
|
|
inherited_environments,
|
|
analytics_events_client,
|
|
image_store,
|
|
thread_store,
|
|
resume_preparation,
|
|
parent_rollout_thread_trace,
|
|
attestation_provider,
|
|
external_time_provider,
|
|
multi_agent_version,
|
|
git_enrichment_policy,
|
|
windows_sandbox_proxy_settings_mode,
|
|
))
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Failed to create session: {e:#}");
|
|
match e.downcast::<CodexErr>() {
|
|
Ok(error) => error,
|
|
Err(error) => map_session_init_error(&error, &config.codex_home),
|
|
}
|
|
})?;
|
|
if let Some(message) = initial_service_tier_warning {
|
|
session
|
|
.send_event_raw(Event {
|
|
id: INITIAL_SUBMIT_ID.to_owned(),
|
|
msg: EventMsg::Warning(WarningEvent { message }),
|
|
})
|
|
.await;
|
|
}
|
|
let thread_id = session.thread_id;
|
|
|
|
// This task will run until Op::Shutdown is received.
|
|
let tree_teardown = startup
|
|
.as_ref()
|
|
.and_then(|startup| startup.session_teardown(thread_id));
|
|
let session_for_loop = Arc::clone(&session);
|
|
let session_loop_handle = tokio::spawn(Box::pin(storage_originator.scope(async move {
|
|
submission_loop(session_for_loop, configured_config, rx_sub)
|
|
.instrument(info_span!("session_loop", thread_id = %thread_id))
|
|
.await;
|
|
if let Some(tree_teardown) = tree_teardown {
|
|
tree_teardown.complete();
|
|
}
|
|
})));
|
|
let io = SessionIo {
|
|
tx_sub,
|
|
rx_event,
|
|
agent_status: agent_status_rx,
|
|
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
|
|
};
|
|
|
|
if let Some(startup) = startup {
|
|
let _ = startup.io.set(io.clone());
|
|
startup.persistence.lock().await.commit();
|
|
}
|
|
Ok((session, io))
|
|
}
|
|
}
|
|
|
|
impl SessionIo {
|
|
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
|
pub(crate) async fn submit(
|
|
&self,
|
|
op: impl Into<WithTurnExtensionData<Op>>,
|
|
) -> CodexResult<String> {
|
|
self.submit_with_trace(
|
|
op, /*trace*/ None, /*parent_turn_id*/ None, /*root_turn_id*/ None,
|
|
/*residency_guard*/ None, /*message_delivery*/ None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn submit_with_trace(
|
|
&self,
|
|
op: impl Into<WithTurnExtensionData<Op>>,
|
|
trace: Option<W3cTraceContext>,
|
|
parent_turn_id: Option<String>,
|
|
root_turn_id: Option<String>,
|
|
residency_guard: Option<tokio::sync::OwnedRwLockReadGuard<()>>,
|
|
message_delivery: Option<crate::agent_communication::MessageDelivery>,
|
|
) -> CodexResult<String> {
|
|
let id = new_submission_id();
|
|
let WithTurnExtensionData {
|
|
request: op,
|
|
turn_extension_init,
|
|
} = op.into();
|
|
let sub = Submission {
|
|
id: id.clone(),
|
|
op,
|
|
turn_extension_init,
|
|
trace,
|
|
parent_turn_id,
|
|
root_turn_id,
|
|
residency_guard,
|
|
message_delivery,
|
|
};
|
|
self.submit_with_id(sub).await?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Use sparingly: prefer `submit()` so submission IDs are generated consistently.
|
|
pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
|
|
if sub.trace.is_none() {
|
|
sub.trace = current_span_w3c_trace_context();
|
|
}
|
|
self.tx_sub
|
|
.send(sub)
|
|
.await
|
|
.map_err(|_| CodexErr::InternalAgentDied)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Submits an ordered turn-input call and waits only for Core's routing decision.
|
|
///
|
|
/// Once queued, dropping the waiter does not retract the call. If the
|
|
/// session loop exits before replying, the caller gets `InternalAgentDied`.
|
|
pub(crate) async fn submit_turn_input(
|
|
&self,
|
|
request: impl Into<WithTurnExtensionData<TurnInputRequest>>,
|
|
mode: TurnInputMode,
|
|
) -> CodexResult<TurnInputSubmission> {
|
|
let id = new_submission_id();
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
let WithTurnExtensionData {
|
|
mut request,
|
|
turn_extension_init,
|
|
} = request.into();
|
|
let trace = request.trace.take();
|
|
self.submit_with_id(Submission {
|
|
id,
|
|
op: Op::TurnInput {
|
|
request: Box::new(request),
|
|
mode,
|
|
reply: reply_tx,
|
|
},
|
|
trace,
|
|
turn_extension_init,
|
|
parent_turn_id: None,
|
|
root_turn_id: None,
|
|
residency_guard: None,
|
|
message_delivery: None,
|
|
})
|
|
.await?;
|
|
reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
|
|
}
|
|
|
|
pub(crate) async fn submit_recover_turn(
|
|
&self,
|
|
thread_settings: ThreadSettingsOverrides,
|
|
start_options: TurnStartOptions,
|
|
trace: Option<W3cTraceContext>,
|
|
turn_id: String,
|
|
turn_extension_init: Option<ExtensionDataInit>,
|
|
) -> CodexResult<TurnInputSubmission> {
|
|
let (reply_tx, reply_rx) = oneshot::channel();
|
|
self.submit_with_id(Submission {
|
|
id: turn_id,
|
|
op: Op::RecoverTurn {
|
|
thread_settings,
|
|
start_options,
|
|
reply: reply_tx,
|
|
},
|
|
trace,
|
|
turn_extension_init,
|
|
parent_turn_id: None,
|
|
root_turn_id: None,
|
|
residency_guard: None,
|
|
message_delivery: None,
|
|
})
|
|
.await?;
|
|
reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
|
|
}
|
|
|
|
pub(crate) async fn shutdown_and_wait(&self) -> CodexResult<()> {
|
|
let session_loop_termination = self.session_loop_termination.clone();
|
|
match self.submit(Op::Shutdown).await {
|
|
Ok(_) => {}
|
|
Err(err) if matches!(err.details(), CodexErrorDetails::InternalAgentDied) => {}
|
|
Err(err) => return Err(err),
|
|
}
|
|
session_loop_termination.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn next_event(&self) -> CodexResult<Event> {
|
|
let event = self
|
|
.rx_event
|
|
.recv()
|
|
.await
|
|
.map_err(|_| CodexErr::InternalAgentDied)?;
|
|
Ok(event)
|
|
}
|
|
|
|
pub(crate) async fn agent_status(&self) -> AgentStatus {
|
|
self.agent_status.borrow().clone()
|
|
}
|
|
}
|
|
|
|
/// Generate a core submission ID. App-server exposes submission IDs that
|
|
/// create turns as a public-facing turn ID.
|
|
///
|
|
/// Some use cases take advantage of the fact that these are UUID7 which
|
|
/// encodes a timestamp, so think carefully before changing this.
|
|
pub(crate) fn new_submission_id() -> String {
|
|
Uuid::now_v7().to_string()
|
|
}
|
|
|
|
pub(crate) fn get_service_tier(
|
|
configured_service_tier: Option<String>,
|
|
features: &Features,
|
|
model_info: &ModelInfo,
|
|
) -> Option<String> {
|
|
let service_tier = configured_service_tier?;
|
|
if service_tier == ServiceTier::Flex.request_value() {
|
|
return Some(service_tier);
|
|
}
|
|
if features.service_tier_enabled(&service_tier)
|
|
&& (service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE
|
|
|| model_info.supports_service_tier(&service_tier))
|
|
{
|
|
return Some(service_tier);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn unsupported_service_tier_warning(
|
|
configured_service_tier: Option<&str>,
|
|
features: &Features,
|
|
model_info: &ModelInfo,
|
|
) -> Option<String> {
|
|
let service_tier = configured_service_tier.filter(|service_tier| {
|
|
features.service_tier_enabled(service_tier)
|
|
&& *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
|
|
&& !model_info.supports_service_tier(service_tier)
|
|
})?;
|
|
Some(format!(
|
|
"Configured service tier `{service_tier}` is not advertised as supported for model `{}` and will be omitted from requests.",
|
|
model_info.slug
|
|
))
|
|
}
|
|
|
|
fn session_permission_profile_state_from_config(
|
|
config: &Config,
|
|
) -> CodexResult<PermissionProfileState> {
|
|
Ok(config.permissions.permission_profile_state().clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
|
|
futures::future::ready(()).boxed().shared()
|
|
}
|
|
|
|
pub(crate) fn session_loop_termination_from_handle(
|
|
handle: JoinHandle<()>,
|
|
) -> SessionLoopTermination {
|
|
async move {
|
|
let _ = handle.await;
|
|
}
|
|
.boxed()
|
|
.shared()
|
|
}
|
|
|
|
async fn thread_title_from_thread_store(
|
|
live_thread: Option<&LiveThread>,
|
|
thread_store: &Arc<dyn ThreadStore>,
|
|
conversation_id: ThreadId,
|
|
) -> Option<String> {
|
|
let thread = match live_thread {
|
|
Some(live_thread) => {
|
|
live_thread
|
|
.read_thread(
|
|
/*include_archived*/ true, /*include_history*/ false,
|
|
)
|
|
.await
|
|
}
|
|
None => {
|
|
thread_store
|
|
.read_thread(ReadThreadParams {
|
|
thread_id: conversation_id,
|
|
include_archived: true,
|
|
include_history: false,
|
|
})
|
|
.await
|
|
}
|
|
}
|
|
.ok()?;
|
|
|
|
let title = thread.name.as_deref()?.trim();
|
|
(!title.is_empty() && thread.preview.trim() != title).then(|| title.to_string())
|
|
}
|
|
|
|
impl Session {
|
|
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
|
let state = self.state.lock().await;
|
|
AppServerClientMetadata {
|
|
client_name: state.session_configuration.app_server_client_name.clone(),
|
|
client_version: state
|
|
.session_configuration
|
|
.app_server_client_version
|
|
.clone(),
|
|
}
|
|
}
|
|
|
|
fn managed_network_proxy_active_for_permission_profile(
|
|
permission_profile: &PermissionProfile,
|
|
) -> bool {
|
|
!matches!(permission_profile, PermissionProfile::Disabled)
|
|
}
|
|
|
|
/// Builds the `x-codex-beta-features` header value for this session.
|
|
///
|
|
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so
|
|
/// we precompute the comma-separated list of enabled experimental feature keys at session
|
|
/// creation time and thread it into the client. Remote compaction stays advertised unconditionally.
|
|
fn build_model_client_beta_features_header(config: &Config) -> Option<String> {
|
|
Some(
|
|
FEATURES
|
|
.iter()
|
|
.filter(|spec| {
|
|
spec.id == Feature::RemoteCompactionV2
|
|
|| (spec.stage.experimental_menu_description().is_some()
|
|
&& config.features.enabled(spec.id))
|
|
})
|
|
.map(|spec| spec.key)
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn start_managed_network_proxy(
|
|
spec: &crate::config::NetworkProxySpec,
|
|
exec_policy: &codex_execpolicy::Policy,
|
|
permission_profile: &PermissionProfile,
|
|
windows_sandbox_type: SandboxType,
|
|
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
|
|
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
|
|
managed_network_requirements_enabled: bool,
|
|
audit_metadata: NetworkProxyAuditMetadata,
|
|
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
|
|
let spec = spec
|
|
.with_exec_policy_network_rules(exec_policy)
|
|
.map_err(|err| {
|
|
tracing::warn!(
|
|
"failed to apply execpolicy network rules to managed proxy; continuing with configured network policy: {err}"
|
|
);
|
|
err
|
|
})
|
|
.unwrap_or_else(|_| spec.clone());
|
|
let network_proxy = spec
|
|
.start_proxy(
|
|
permission_profile,
|
|
managed_proxy_routing_for_windows_sandbox(windows_sandbox_type),
|
|
local_binding_policy_for_sandbox(windows_sandbox_type, Some(std::env::consts::OS)),
|
|
network_policy_decider,
|
|
blocked_request_observer,
|
|
managed_network_requirements_enabled,
|
|
audit_metadata,
|
|
)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?;
|
|
let session_network_proxy = {
|
|
let proxy = network_proxy.proxy();
|
|
SessionNetworkProxyRuntime {
|
|
http_addr: proxy.http_addr().to_string(),
|
|
socks_addr: proxy.socks_addr().to_string(),
|
|
}
|
|
};
|
|
Ok((network_proxy, session_network_proxy))
|
|
}
|
|
|
|
async fn refresh_managed_network_proxy_for_current_permission_profile(&self) {
|
|
let Ok(_refresh_guard) = self.managed_network_proxy_refresh_lock.acquire().await else {
|
|
error!("managed network proxy refresh semaphore closed");
|
|
return;
|
|
};
|
|
let session_configuration = {
|
|
let state = self.state.lock().await;
|
|
state.session_configuration.clone()
|
|
};
|
|
let Some(spec) = session_configuration
|
|
.original_config_do_not_use
|
|
.permissions
|
|
.network
|
|
.as_ref()
|
|
.cloned()
|
|
else {
|
|
self.services.network_proxy.store(None);
|
|
self.services
|
|
.turn_environments
|
|
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Inactive);
|
|
return;
|
|
};
|
|
|
|
let spec = match spec
|
|
.recompute_for_permission_profile(&session_configuration.permission_profile())
|
|
{
|
|
Ok(spec) => spec,
|
|
Err(err) => {
|
|
warn!("failed to rebuild managed network proxy policy for sandbox change: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let current_exec_policy = self.services.exec_policy.current();
|
|
let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) {
|
|
Ok(spec) => spec,
|
|
Err(err) => {
|
|
warn!(
|
|
"failed to apply execpolicy network rules while refreshing managed network proxy: {err}"
|
|
);
|
|
spec
|
|
}
|
|
};
|
|
// Disabled specs still carry managed requirements and constraints, but they do not have
|
|
// listeners and must not be exposed as active managed proxy runtimes.
|
|
if !spec.enabled() {
|
|
self.services.network_proxy.store(None);
|
|
self.services
|
|
.turn_environments
|
|
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Inactive);
|
|
return;
|
|
}
|
|
let managed_proxy_routing =
|
|
managed_proxy_routing_for_windows_sandbox(session_configuration.windows_sandbox_type);
|
|
let started_proxy = self.services.network_proxy.load_full();
|
|
let network_policy_decider = started_proxy
|
|
.as_ref()
|
|
.and_then(|started_proxy| started_proxy.network_policy_decider());
|
|
if let Some(started_proxy) = started_proxy
|
|
&& started_proxy.proxy().managed_proxy_routing() == managed_proxy_routing
|
|
{
|
|
if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await {
|
|
warn!("failed to refresh managed network proxy for sandbox change: {err}");
|
|
} else {
|
|
self.services
|
|
.turn_environments
|
|
.set_snapshot_credential_broker(if spec.credential_broker_enabled() {
|
|
SnapshotCredentialBrokerState::Ready(started_proxy.proxy())
|
|
} else {
|
|
SnapshotCredentialBrokerState::Inactive
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
match Self::start_managed_network_proxy(
|
|
&spec,
|
|
current_exec_policy.as_ref(),
|
|
&session_configuration.permission_profile(),
|
|
session_configuration.windows_sandbox_type,
|
|
network_policy_decider,
|
|
Some(build_blocked_request_observer(Arc::clone(
|
|
&self.services.network_approval,
|
|
))),
|
|
self.services.managed_network_requirements_configured,
|
|
self.services.network_proxy_audit_metadata.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok((started_proxy, _session_network_proxy)) => {
|
|
if spec.credential_broker_enabled() {
|
|
self.services
|
|
.turn_environments
|
|
.set_snapshot_credential_broker(SnapshotCredentialBrokerState::Ready(
|
|
started_proxy.proxy(),
|
|
));
|
|
}
|
|
self.services
|
|
.network_proxy
|
|
.store(Some(Arc::new(started_proxy)));
|
|
}
|
|
Err(err) => {
|
|
self.services
|
|
.turn_environments
|
|
.set_snapshot_credential_broker(if spec.credential_broker_enabled() {
|
|
SnapshotCredentialBrokerState::Unavailable
|
|
} else {
|
|
SnapshotCredentialBrokerState::Inactive
|
|
});
|
|
warn!("failed to start managed network proxy for sandbox change: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn codex_home(&self) -> AbsolutePathBuf {
|
|
let state = self.state.lock().await;
|
|
state.session_configuration.codex_home().clone()
|
|
}
|
|
|
|
pub(crate) fn subscribe_elicitation_pause_state(&self) -> watch::Receiver<bool> {
|
|
self.services.elicitations.subscribe()
|
|
}
|
|
|
|
pub(crate) fn mark_interrupted(&self) {
|
|
self.agent_status.send_replace(AgentStatus::Interrupted);
|
|
}
|
|
|
|
pub(crate) fn is_interrupted(&self) -> bool {
|
|
matches!(*self.agent_status.borrow(), AgentStatus::Interrupted)
|
|
}
|
|
|
|
pub(crate) fn get_tx_event(&self) -> Sender<Event> {
|
|
self.tx_event.clone()
|
|
}
|
|
|
|
pub(crate) fn state_db(&self) -> Option<state_db::StateDbHandle> {
|
|
self.services.state_db.clone()
|
|
}
|
|
|
|
pub(crate) fn live_thread_for_persistence(
|
|
&self,
|
|
operation: &str,
|
|
) -> anyhow::Result<&LiveThread> {
|
|
self.live_thread()
|
|
.ok_or_else(|| anyhow::anyhow!("Session persistence is disabled; cannot {operation}."))
|
|
}
|
|
|
|
pub(crate) fn live_thread(&self) -> Option<&LiveThread> {
|
|
self.services.live_thread.as_ref()
|
|
}
|
|
|
|
pub(crate) async fn set_thread_memory_mode(
|
|
self: &Arc<Self>,
|
|
mode: ThreadMemoryMode,
|
|
) -> anyhow::Result<()> {
|
|
handlers::persist_thread_memory_mode_update(self, mode).await
|
|
}
|
|
|
|
/// Flush rollout writes and return the final durability-barrier result.
|
|
#[instrument(name = "session.flush_rollout", level = "trace", skip_all)]
|
|
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
|
|
if let Some(live_thread) = self.live_thread() {
|
|
live_thread.flush().await.map_err(std::io::Error::other)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn try_ensure_rollout_materialized(
|
|
&self,
|
|
context: PersistContext,
|
|
) -> std::io::Result<()> {
|
|
if let Some(live_thread) = self.live_thread() {
|
|
live_thread
|
|
.persist(context)
|
|
.await
|
|
.map_err(std::io::Error::other)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn ensure_rollout_materialized(&self, context: PersistContext) {
|
|
if let Err(e) = self.try_ensure_rollout_materialized(context).await {
|
|
warn!("failed to materialize thread persistence: {e}");
|
|
}
|
|
}
|
|
|
|
fn next_internal_sub_id(&self) -> String {
|
|
let id = self
|
|
.next_internal_sub_id
|
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
format!("auto-compact-{id}")
|
|
}
|
|
|
|
pub(crate) async fn get_total_token_usage(&self) -> i64 {
|
|
let state = self.state.lock().await;
|
|
state.get_total_token_usage(state.server_reasoning_included())
|
|
}
|
|
|
|
pub(crate) async fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
|
|
let state = self.state.lock().await;
|
|
state.auto_compact_window_snapshot()
|
|
}
|
|
|
|
pub(crate) async fn estimated_tokens_after_last_model_generated_item(&self) -> i64 {
|
|
let state = self.state.lock().await;
|
|
state
|
|
.history
|
|
.estimated_tokens_after_last_model_generated_item()
|
|
}
|
|
|
|
pub(crate) async fn total_token_usage(&self) -> Option<TokenUsage> {
|
|
let state = self.state.lock().await;
|
|
state.token_info().map(|info| info.total_token_usage)
|
|
}
|
|
|
|
/// Returns the complete token usage snapshot currently cached for this session.
|
|
///
|
|
/// Resume and fork reconstruction seed this state from the last persisted rollout
|
|
/// `TokenCount` event. Callers that need to replay restored usage to a client
|
|
/// should use this accessor instead of `total_token_usage`, because the app-server
|
|
/// notification includes both total and last-turn usage.
|
|
pub(crate) async fn token_usage_info(&self) -> Option<TokenUsageInfo> {
|
|
let state = self.state.lock().await;
|
|
state.token_info()
|
|
}
|
|
|
|
pub(crate) async fn get_estimated_token_count(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
) -> Option<i64> {
|
|
let history = self.clone_history().await;
|
|
history.estimate_token_count(turn_context)
|
|
}
|
|
|
|
pub(crate) async fn get_base_instructions(&self) -> BaseInstructions {
|
|
let state = self.state.lock().await;
|
|
BaseInstructions {
|
|
text: state.session_configuration.base_instructions.clone(),
|
|
provenance: state.base_instructions_provenance.clone(),
|
|
}
|
|
}
|
|
|
|
/// Render the request copy without changing instructions persisted or inherited by forks.
|
|
pub(crate) async fn get_prompt_base_instructions(&self) -> BaseInstructions {
|
|
let config = self.get_config().await;
|
|
let instructions = self.get_base_instructions().await;
|
|
if !config.update_plan_enabled
|
|
&& config.model_catalog.is_none()
|
|
&& matches!(
|
|
instructions.provenance,
|
|
Some(BaseInstructionsProvenance::Model { .. })
|
|
)
|
|
{
|
|
BaseInstructions {
|
|
text: codex_prompts::without_update_plan_instructions(&instructions.text),
|
|
..instructions
|
|
}
|
|
} else {
|
|
instructions
|
|
}
|
|
}
|
|
|
|
// Merges connector IDs into the session-level explicit connector selection.
|
|
#[tracing::instrument(
|
|
level = "trace",
|
|
skip_all,
|
|
fields(connector_count = connector_ids.len())
|
|
)]
|
|
pub(crate) async fn merge_connector_selection(
|
|
&self,
|
|
connector_ids: HashSet<String>,
|
|
) -> HashSet<String> {
|
|
let mut state = self.state.lock().await;
|
|
state.merge_connector_selection(connector_ids)
|
|
}
|
|
|
|
// Returns the connector IDs currently selected for this session.
|
|
pub(crate) async fn get_connector_selection(&self) -> HashSet<String> {
|
|
let state = self.state.lock().await;
|
|
state.get_connector_selection()
|
|
}
|
|
|
|
// Clears connector IDs that were accumulated for explicit selection.
|
|
pub(crate) async fn clear_connector_selection(&self) {
|
|
let mut state = self.state.lock().await;
|
|
state.clear_connector_selection();
|
|
}
|
|
|
|
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
|
let (is_subagent, is_paginated_subagent) = {
|
|
let state = self.state.lock().await;
|
|
let session_configuration = &state.session_configuration;
|
|
(
|
|
session_configuration.session_source.is_non_root_agent(),
|
|
matches!(
|
|
session_configuration.history_mode,
|
|
ThreadHistoryMode::Paginated
|
|
) && matches!(
|
|
session_configuration.thread_source.as_ref(),
|
|
Some(ThreadSource::Subagent | ThreadSource::GuardianReview)
|
|
),
|
|
)
|
|
};
|
|
let has_prior_user_turns = initial_history_has_prior_user_turns(&conversation_history);
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.set_next_turn_is_first(!has_prior_user_turns);
|
|
}
|
|
let turn_context = match conversation_history {
|
|
InitialHistory::New | InitialHistory::Cleared => {
|
|
// Defer initial context insertion until the first real turn starts so
|
|
// turn/start overrides can be merged before we write model-visible context.
|
|
self.set_previous_turn_settings(/*previous_turn_settings*/ None)
|
|
.await;
|
|
None
|
|
}
|
|
InitialHistory::Resumed(resumed_history) => {
|
|
let turn_context = self.new_default_turn().await;
|
|
let rollout_items = resumed_history.history;
|
|
if matches!(
|
|
rollout_items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::EventMsg(event) => agent_status_from_event(event),
|
|
_ => None,
|
|
}),
|
|
Some(AgentStatus::Interrupted)
|
|
) {
|
|
self.agent_status.send_replace(AgentStatus::Interrupted);
|
|
}
|
|
let previous_turn_settings = self
|
|
.apply_rollout_reconstruction(&turn_context, &rollout_items)
|
|
.await;
|
|
|
|
// If resuming, warn when the last recorded model differs from the current one.
|
|
let curr: &str = turn_context.model_info().slug.as_str();
|
|
if let Some(prev) = previous_turn_settings
|
|
.as_ref()
|
|
.map(|settings| settings.model.as_str())
|
|
.filter(|model| *model != curr)
|
|
{
|
|
warn!("resuming session with different model: previous={prev}, current={curr}");
|
|
self.send_event(
|
|
&turn_context,
|
|
EventMsg::Warning(WarningEvent {
|
|
message: format!(
|
|
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
|
|
Consider switching back to `{prev}` as it may affect Codex performance."
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Seed usage info from the recorded rollout so UIs can show token counts
|
|
// immediately on resume/fork.
|
|
if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
|
|
let mut state = self.state.lock().await;
|
|
state.set_token_info(Some(info));
|
|
}
|
|
self.state.lock().await.latest_token_usage_record =
|
|
Self::last_token_usage_record_from_rollout(&rollout_items);
|
|
|
|
let thread_id = self.thread_id();
|
|
let current_settings = self.thread_settings_snapshot().await;
|
|
let persisted_settings = rollout_items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
|
|
if event.thread_id == Some(thread_id) =>
|
|
{
|
|
Some(&event.thread_settings)
|
|
}
|
|
_ => None,
|
|
});
|
|
// Persist changed effective settings even when no turn follows the resume.
|
|
if persisted_settings != Some(¤t_settings) {
|
|
self.persist_rollout_items(&[RolloutItem::EventMsg(
|
|
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
|
thread_id: Some(thread_id),
|
|
thread_settings: current_settings,
|
|
}),
|
|
)])
|
|
.await;
|
|
}
|
|
|
|
// Defer seeding the session's initial context until the first turn starts so
|
|
// turn/start overrides can be merged before we write model-visible context.
|
|
if !is_subagent {
|
|
let _ = self.flush_rollout().await;
|
|
}
|
|
None
|
|
}
|
|
InitialHistory::Forked(mut rollout_items) => {
|
|
let turn_context = self.new_default_turn().await;
|
|
Self::assign_missing_rollout_response_item_ids(&mut rollout_items);
|
|
self.apply_rollout_reconstruction(&turn_context, &rollout_items)
|
|
.await;
|
|
|
|
// Seed usage info from the recorded rollout so UIs can show token counts
|
|
// immediately on resume/fork.
|
|
if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
|
|
let mut state = self.state.lock().await;
|
|
state.set_token_info(Some(info));
|
|
}
|
|
self.state.lock().await.latest_token_usage_record =
|
|
Self::last_token_usage_record_from_rollout(&rollout_items);
|
|
|
|
let thread_settings_applied =
|
|
RolloutItem::EventMsg(thread_settings::applied_event(self).await);
|
|
match &self.fork_persistence {
|
|
ForkPersistence::Referenced {
|
|
inherited_item_count,
|
|
..
|
|
} => {
|
|
// Ancestor records remain behind history_base; only effective child
|
|
// settings and boundaries synthesized by snapshot processing are local.
|
|
rollout_items.drain(..*inherited_item_count);
|
|
rollout_items.insert(0, thread_settings_applied);
|
|
}
|
|
ForkPersistence::Copied | ForkPersistence::CopiedDeferred
|
|
if is_paginated_subagent =>
|
|
{
|
|
// Paginated subagents already persist inherited context when their live
|
|
// thread is created.
|
|
rollout_items.clear();
|
|
rollout_items.push(thread_settings_applied);
|
|
}
|
|
ForkPersistence::Copied | ForkPersistence::CopiedDeferred => {
|
|
// Keep the copied prefix and effective child settings in one append so a
|
|
// cold resume cannot observe inherited settings as the latest value.
|
|
rollout_items.push(thread_settings_applied);
|
|
}
|
|
}
|
|
self.persist_rollout_items(&rollout_items).await;
|
|
if self.preserve_parent_tool_declarations() {
|
|
let notice = DeveloperInstructions::new(
|
|
"<mcp_tools>\nMCP tools are unavailable for this request.\n</mcp_tools>",
|
|
)
|
|
.with_metadata(ContentItemMetadata::tool(/*namespace*/ None));
|
|
self.record_conversation_items(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
&[ContextualUserFragment::into(notice)],
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Agent spawn owns its final durability barrier so it can overlap the edge write.
|
|
let persist_context = match self.fork_persistence {
|
|
ForkPersistence::CopiedDeferred => PersistContext::SubagentSpawn,
|
|
ForkPersistence::Copied | ForkPersistence::Referenced { .. } => {
|
|
PersistContext::Standard
|
|
}
|
|
};
|
|
self.ensure_rollout_materialized(persist_context).await;
|
|
|
|
// Flush after seeding history and any persisted rollout copy.
|
|
if !is_subagent {
|
|
let _ = self.flush_rollout().await;
|
|
}
|
|
Some(turn_context)
|
|
}
|
|
};
|
|
if let Some(turn_context) = turn_context
|
|
&& turn_context.config.memories.disable_on_external_context
|
|
{
|
|
let history = self.clone_history().await;
|
|
if let Some(item) = history
|
|
.raw_items()
|
|
.find(|item| matches!(item, ResponseItem::FunctionCallOutput { call_id: None, .. }))
|
|
{
|
|
mark_thread_memory_mode_polluted_if_external_context(self, &turn_context, item)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[instrument(
|
|
level = "trace",
|
|
skip_all,
|
|
fields(
|
|
thread_id = %self.thread_id(),
|
|
rollout_item_count = rollout_items.len()
|
|
)
|
|
)]
|
|
async fn apply_rollout_reconstruction(
|
|
&self,
|
|
turn_context: &Arc<TurnContext>,
|
|
rollout_items: &[RolloutItem],
|
|
) -> Option<PreviousTurnSettings> {
|
|
let rollout_reconstruction::RolloutReconstruction {
|
|
mut history,
|
|
retained_context,
|
|
guardian_history,
|
|
last_started_turn_id,
|
|
previous_turn_settings,
|
|
reference_context_item,
|
|
turn_attribution,
|
|
world_state_baseline,
|
|
window_number,
|
|
first_window_id,
|
|
previous_window_id,
|
|
window_id,
|
|
} = self
|
|
.reconstruct_history_from_rollout(turn_context, rollout_items)
|
|
.await;
|
|
// Keep the recorded rollout unchanged. Prepare its reconstructed history before
|
|
// installing it, so legacy media is processed once for this resume or fork and
|
|
// will be processed again if the rollout is reconstructed in a future session.
|
|
// Replay disables image-resize notices, so media preparation remains one-to-one. Keep
|
|
// the prior batch behavior and carry history-only metadata in a positional sidecar.
|
|
let (mut prepared_history, metadata): (Vec<_>, Vec<_>) = history
|
|
.into_iter()
|
|
.map(|envelope| (envelope.item, envelope.metadata))
|
|
.unzip();
|
|
// Replay must not upload or migrate recorded history. The inline store returns prepared
|
|
// inline bytes, while existing file references bypass preparation and remain unchanged.
|
|
// Bound replay future size now that image preparation can await storage.
|
|
let _ = Box::pin(prepare_image_response_items(
|
|
&self.thread_id.to_string(),
|
|
turn_context.config.ephemeral,
|
|
&mut prepared_history,
|
|
ImagePreparationMode::DetailBased,
|
|
ImageResizeNoticeMode::Disabled,
|
|
&InlineAttachmentStore,
|
|
))
|
|
.await;
|
|
prepare_audio_response_items(&mut prepared_history);
|
|
assert_eq!(
|
|
prepared_history.len(),
|
|
metadata.len(),
|
|
"replay media preparation must remain one-to-one when resize notices are disabled"
|
|
);
|
|
history = prepared_history
|
|
.into_iter()
|
|
.zip(metadata)
|
|
.map(|(item, metadata)| ResponseItemEnvelope { item, metadata })
|
|
.collect();
|
|
// Ordinary replay needs no reviewer or network refresh. Only an opaque
|
|
// checkpoint can make reconstruction depend on reviewer compatibility.
|
|
let reviewer_compaction_hash = if codex_history::CompactionCheckpoint::latest(&history)
|
|
.is_some_and(codex_history::CompactionCheckpoint::is_usable)
|
|
&& turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::GuardianReuseParentCompaction)
|
|
{
|
|
let context = crate::guardian::GuardianReviewContext::from(turn_context);
|
|
let (_, reviewer) = crate::guardian::resolve_review_model(self, &context).await;
|
|
reviewer.comp_hash.clone()
|
|
} else {
|
|
None
|
|
};
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.replace_annotated_history(
|
|
history,
|
|
reference_context_item,
|
|
HistoryReplacement::Reset,
|
|
);
|
|
state.history.restore_review_context(
|
|
Some(&retained_context),
|
|
guardian_history.as_ref(),
|
|
reviewer_compaction_hash.as_deref(),
|
|
);
|
|
state.last_started_turn_id = last_started_turn_id;
|
|
state.turn_attribution = turn_attribution;
|
|
if let Some(world_state) = world_state_baseline {
|
|
state.history.set_world_state_baseline(world_state);
|
|
}
|
|
let fallback_ids = state.auto_compact_window_ids();
|
|
let window_id = window_id.unwrap_or(fallback_ids.window_id);
|
|
state.restore_auto_compact_window(
|
|
window_number,
|
|
AutoCompactWindowIds {
|
|
first_window_id: first_window_id.unwrap_or(window_id),
|
|
previous_window_id,
|
|
window_id,
|
|
},
|
|
);
|
|
state.set_previous_turn_settings(previous_turn_settings.clone());
|
|
}
|
|
let prefix_tokens = if matches!(
|
|
turn_context.config.model_auto_compact_token_limit_scope,
|
|
AutoCompactTokenLimitScope::BodyAfterPrefix
|
|
) {
|
|
let history = self.clone_history().await;
|
|
let base_instructions = self.get_base_instructions().await;
|
|
history.estimate_token_count_with_base_instructions(&base_instructions)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(prefix_tokens) = prefix_tokens {
|
|
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
|
|
.await;
|
|
}
|
|
previous_turn_settings
|
|
}
|
|
|
|
async fn set_auto_compact_window_estimated_prefill_for_scope(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
tokens: i64,
|
|
) {
|
|
if !matches!(
|
|
turn_context.config.model_auto_compact_token_limit_scope,
|
|
AutoCompactTokenLimitScope::BodyAfterPrefix
|
|
) {
|
|
return;
|
|
}
|
|
|
|
let mut state = self.state.lock().await;
|
|
state.set_auto_compact_window_estimated_prefill(tokens);
|
|
}
|
|
|
|
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
|
|
rollout_items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn last_token_usage_record_from_rollout(
|
|
rollout_items: &[RolloutItem],
|
|
) -> Option<TokenUsageRecord> {
|
|
for item in rollout_items.iter().rev() {
|
|
match item {
|
|
RolloutItem::TokenUsageRecord(record) => return Some(record.clone()),
|
|
RolloutItem::Compacted(compacted) => {
|
|
return compacted.latest_token_usage_record.clone();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn previous_turn_settings(&self) -> Option<PreviousTurnSettings> {
|
|
let state = self.state.lock().await;
|
|
state.previous_turn_settings()
|
|
}
|
|
|
|
#[tracing::instrument(level = "trace", skip_all)]
|
|
pub(crate) async fn set_previous_turn_settings(
|
|
&self,
|
|
previous_turn_settings: Option<PreviousTurnSettings>,
|
|
) {
|
|
let mut state = self.state.lock().await;
|
|
state.set_previous_turn_settings(previous_turn_settings);
|
|
}
|
|
|
|
pub(crate) async fn update_settings(
|
|
&self,
|
|
updates: SessionSettingsUpdate,
|
|
) -> ConstraintResult<SessionSettingsCommit> {
|
|
let Some(commit) = self.update_settings_if(updates, |_, _| true).await? else {
|
|
unreachable!("unconditional settings updates must commit");
|
|
};
|
|
Ok(commit)
|
|
}
|
|
|
|
/// Evaluates the caller's synchronous predicate against the current configuration
|
|
/// and validated candidate under the same state lock used for publication.
|
|
/// This prevents a stale admission decision. Rejection has no settings or runtime
|
|
/// effects.
|
|
///
|
|
/// The predicate must be fast and side-effect-free. It must not block, acquire
|
|
/// other locks, or call back into `Session`.
|
|
async fn update_settings_if(
|
|
&self,
|
|
updates: SessionSettingsUpdate,
|
|
should_commit: impl FnOnce(&SessionConfiguration, &SessionConfiguration) -> bool + Send,
|
|
) -> ConstraintResult<Option<SessionSettingsCommit>> {
|
|
let notify_config_contributors = !self.services.extensions.config_contributors().is_empty();
|
|
let windows_sandbox_changed;
|
|
let (
|
|
commit,
|
|
previous_config,
|
|
new_config,
|
|
permission_profile_changed,
|
|
mcp_inputs_changed,
|
|
credential_masking_warning,
|
|
) = {
|
|
let mut state = self.state.lock().await;
|
|
let updated = match self.apply_session_settings(&state.session_configuration, &updates)
|
|
{
|
|
Ok(updated) => updated,
|
|
Err(err) => {
|
|
warn!("rejected session settings update: {err}");
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
if !should_commit(&state.session_configuration, &updated) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let credential_masking_warning = updated
|
|
.original_config_do_not_use
|
|
.credential_masking_warning(&updated.permission_profile())
|
|
.filter(|warning| {
|
|
Some(*warning)
|
|
!= state
|
|
.session_configuration
|
|
.original_config_do_not_use
|
|
.credential_masking_warning(
|
|
&state.session_configuration.permission_profile(),
|
|
)
|
|
});
|
|
let previous_config = notify_config_contributors
|
|
.then(|| self.build_effective_session_config(&state.session_configuration));
|
|
let previous_permission_profile = state.session_configuration.permission_profile();
|
|
let updated_permission_profile = updated.permission_profile();
|
|
let permission_profile_changed =
|
|
previous_permission_profile != updated_permission_profile;
|
|
let root_service_tier_changed = updated.parent_thread_id.is_none()
|
|
&& state.session_configuration.step_settings.service_tier
|
|
!= updated.step_settings.service_tier;
|
|
let mcp_inputs_changed = self.mcp_inputs_differ(&state.session_configuration, &updated);
|
|
if mcp_inputs_changed {
|
|
self.mark_mcp_runtime_dirty();
|
|
}
|
|
// Save new environment defaults for future turns. The running turn keeps its own.
|
|
windows_sandbox_changed =
|
|
state.session_configuration.windows_sandbox_level != updated.windows_sandbox_level;
|
|
self.services
|
|
.turn_environments
|
|
.set_configured_thread_defaults(updated.inferred_environment_config());
|
|
if updated.environments != state.session_configuration.environments {
|
|
self.services
|
|
.turn_environments
|
|
.update_configured_selections(&updated.environments);
|
|
}
|
|
state.session_configuration = updated;
|
|
if root_service_tier_changed {
|
|
self.services.agent_control.propagate_config_update(
|
|
AgentConfigUpdate::ServiceTier(
|
|
state
|
|
.session_configuration
|
|
.step_settings
|
|
.service_tier
|
|
.clone(),
|
|
),
|
|
);
|
|
}
|
|
let new_config = notify_config_contributors
|
|
.then(|| self.build_effective_session_config(&state.session_configuration));
|
|
let commit = SessionSettingsCommit {
|
|
configuration: state.session_configuration.clone(),
|
|
snapshot: state
|
|
.session_configuration
|
|
.thread_settings_snapshot(&state.session_configuration.environments),
|
|
};
|
|
(
|
|
commit,
|
|
previous_config,
|
|
new_config,
|
|
permission_profile_changed,
|
|
mcp_inputs_changed,
|
|
credential_masking_warning,
|
|
)
|
|
};
|
|
if windows_sandbox_changed {
|
|
crate::windows_sandbox::log_windows_sandbox_change(
|
|
commit.configuration.codex_home.as_path(),
|
|
commit.configuration.windows_sandbox_type,
|
|
commit.configuration.windows_sandbox_level,
|
|
);
|
|
}
|
|
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
|
|
if permission_profile_changed {
|
|
self.refresh_managed_network_proxy_for_current_permission_profile()
|
|
.await;
|
|
}
|
|
if let Some(message) = credential_masking_warning {
|
|
warn!("{message}");
|
|
self.send_event_raw(Event {
|
|
id: INITIAL_SUBMIT_ID.to_owned(),
|
|
msg: EventMsg::Warning(WarningEvent {
|
|
message: message.to_string(),
|
|
}),
|
|
})
|
|
.await;
|
|
}
|
|
if mcp_inputs_changed {
|
|
self.schedule_mcp_prewarm();
|
|
}
|
|
Ok(Some(commit))
|
|
}
|
|
|
|
pub(crate) async fn preview_settings(
|
|
&self,
|
|
updates: &SessionSettingsUpdate,
|
|
) -> ConstraintResult<ThreadConfigSnapshot> {
|
|
let configuration = {
|
|
let state = self.state.lock().await;
|
|
self.apply_session_settings(&state.session_configuration, updates)?
|
|
};
|
|
Ok(self
|
|
.effective_thread_config_snapshot(&configuration, configuration.environments.clone())
|
|
.await)
|
|
}
|
|
|
|
pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
|
|
self.resume_config_snapshot().await.0
|
|
}
|
|
|
|
pub(crate) async fn resume_config_snapshot(&self) -> (ThreadConfigSnapshot, Option<String>) {
|
|
let configuration = self.state.lock().await.session_configuration.clone();
|
|
let selected_service_tier = configuration.step_settings.service_tier.clone();
|
|
let snapshot = self
|
|
.effective_thread_config_snapshot(&configuration, configuration.environments.clone())
|
|
.await;
|
|
(snapshot, selected_service_tier)
|
|
}
|
|
|
|
/// Captures configured child bindings, or the active turn's bindings for legacy inheritance.
|
|
pub(crate) async fn inherited_environment_snapshot(&self) -> TurnEnvironmentSnapshot {
|
|
let snapshot = {
|
|
let _state = self.state.lock().await;
|
|
if self.features.enabled(Feature::SubagentThreadEnvironments) {
|
|
return self.services.turn_environments.configured_snapshot_now();
|
|
}
|
|
self.services.turn_environments.snapshot()
|
|
};
|
|
snapshot.await
|
|
}
|
|
|
|
pub(crate) async fn configured_environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
|
|
let state = self.state.lock().await;
|
|
state.session_configuration.environments.clone()
|
|
}
|
|
|
|
pub(crate) async fn thread_settings_snapshot(&self) -> ThreadSettingsSnapshot {
|
|
let state = self.state.lock().await;
|
|
state
|
|
.session_configuration
|
|
.thread_settings_snapshot(&state.session_configuration.environments)
|
|
}
|
|
|
|
pub(crate) async fn restorable_thread_settings(&self) -> CodexThreadSettingsOverrides {
|
|
let state = self.state.lock().await;
|
|
state
|
|
.session_configuration
|
|
.restorable_thread_settings(state.session_configuration.environments.clone())
|
|
}
|
|
|
|
pub(crate) async fn set_app_server_client_info(
|
|
&self,
|
|
app_server_client_name: Option<String>,
|
|
app_server_client_version: Option<String>,
|
|
mcp_elicitations_auto_deny: bool,
|
|
) -> ConstraintResult<()> {
|
|
self.update_settings(SessionSettingsUpdate {
|
|
app_server_client_name,
|
|
app_server_client_version,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
self.services
|
|
.mcp_runtime
|
|
.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn instruction_sources(&self) -> Vec<PathUri> {
|
|
self.services
|
|
.agents_md_manager
|
|
.get_loaded()
|
|
.await
|
|
.as_ref()
|
|
.map_or_else(Vec::new, |instructions| instructions.sources().collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn set_session_startup_prewarm(
|
|
&self,
|
|
startup_prewarm: SessionStartupPrewarmHandle,
|
|
) {
|
|
let mut state = self.state.lock().await;
|
|
state.set_session_startup_prewarm(startup_prewarm);
|
|
}
|
|
|
|
pub(crate) async fn take_session_startup_prewarm(&self) -> Option<SessionStartupPrewarmHandle> {
|
|
let mut state = self.state.lock().await;
|
|
state.take_session_startup_prewarm()
|
|
}
|
|
|
|
pub(crate) async fn fork_config(
|
|
&self,
|
|
) -> (
|
|
Config,
|
|
ThreadConfigSnapshot,
|
|
WindowsSandboxLevel,
|
|
TurnEnvironmentSnapshot,
|
|
ReasoningEffortPin,
|
|
) {
|
|
let (configuration, environments, mut reasoning_effort_pin) = {
|
|
let state = self.state.lock().await;
|
|
(
|
|
state.session_configuration.clone(),
|
|
self.services.turn_environments.snapshot_now(),
|
|
state.reasoning_effort_pin.clone(),
|
|
)
|
|
};
|
|
let config = self.build_effective_session_config(&configuration);
|
|
if !config.features.enabled(Feature::ReasoningEffortOverride) {
|
|
let model = configuration
|
|
.step_settings
|
|
.resolve_model_info(
|
|
self.services.models_manager.as_ref(),
|
|
&configuration.model_info_overrides,
|
|
)
|
|
.await;
|
|
if let Some(effort) = config
|
|
.model_reasoning_effort
|
|
.clone()
|
|
.or_else(|| model.default_reasoning_level.clone())
|
|
{
|
|
// Without overrides, the parent's selected effort is its request baseline.
|
|
reasoning_effort_pin = ReasoningEffortPin::Active {
|
|
model: model.slug.clone(),
|
|
effort: model.resolve_reasoning_effort(effort),
|
|
};
|
|
}
|
|
}
|
|
(
|
|
config,
|
|
self.effective_thread_config_snapshot(&configuration, environments.all_selections())
|
|
.await,
|
|
configuration.windows_sandbox_level,
|
|
environments,
|
|
reasoning_effort_pin,
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn get_config(&self) -> std::sync::Arc<Config> {
|
|
let state = self.state.lock().await;
|
|
state
|
|
.session_configuration
|
|
.original_config_do_not_use
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) async fn inherited_instructions(&self) -> SessionInstructions {
|
|
self.services
|
|
.agents_md_manager
|
|
.inherited_instructions()
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn emit_instruction_warnings(&self, warnings: Vec<String>) {
|
|
for message in warnings {
|
|
self.send_event_raw(Event {
|
|
id: INITIAL_SUBMIT_ID.to_owned(),
|
|
msg: EventMsg::Warning(WarningEvent { message }),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn provider(&self) -> ModelProviderInfo {
|
|
let state = self.state.lock().await;
|
|
state.session_configuration.provider.info().clone()
|
|
}
|
|
|
|
pub(crate) async fn refresh_hooks(&self, mut config: Arc<Config>) {
|
|
loop {
|
|
let disabled_plugin_ids = self.state.lock().await.active_disabled_plugin_ids.clone();
|
|
let environments = self.services.turn_environments.snapshot().await;
|
|
let hooks_config = build_hooks_config(
|
|
config.as_ref(),
|
|
self.services.plugins_manager.as_ref(),
|
|
environments.single_local_environment(),
|
|
&disabled_plugin_ids,
|
|
)
|
|
.await;
|
|
|
|
let state = self.state.lock().await;
|
|
if Arc::ptr_eq(
|
|
&state.session_configuration.original_config_do_not_use,
|
|
&config,
|
|
) && state.active_disabled_plugin_ids == disabled_plugin_ids
|
|
{
|
|
let hooks = self.hooks().reconfigured(hooks_config);
|
|
self.services.hooks.store(Arc::new(hooks));
|
|
return;
|
|
}
|
|
// Unrelated updates can replace the owner too. Rebuild current inputs
|
|
// so they cannot strand a pending plugin-policy refresh.
|
|
config = Arc::clone(&state.session_configuration.original_config_do_not_use);
|
|
}
|
|
}
|
|
|
|
fn emit_config_changed_contributors(
|
|
&self,
|
|
previous_config: Option<&Config>,
|
|
new_config: Option<&Config>,
|
|
) {
|
|
let (Some(previous_config), Some(new_config)) = (previous_config, new_config) else {
|
|
return;
|
|
};
|
|
if previous_config == new_config {
|
|
return;
|
|
}
|
|
for contributor in self.services.extensions.config_contributors() {
|
|
contributor.on_config_changed(
|
|
&self.services.session_extension_data,
|
|
&self.services.thread_extension_data,
|
|
previous_config,
|
|
new_config,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn reload_user_config_layer(&self) {
|
|
// Refresh layer-backed runtime state for an existing session, including enabled plugin,
|
|
// skill, hook, and MCP feature state. Other feature gates and legacy notify settings
|
|
// remain session-static.
|
|
//
|
|
// Prefer `refresh_runtime_config()` when the host can already provide a materialized
|
|
// config snapshot. This file-based path exists for legacy local reload flows.
|
|
// Retry from the current owner if another publication wins while files are read.
|
|
loop {
|
|
let (expected_config, config_toml_paths) = {
|
|
let state = self.state.lock().await;
|
|
let config = Arc::clone(&state.session_configuration.original_config_do_not_use);
|
|
let user_config_paths = config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.filter_map(|layer| match &layer.name {
|
|
ConfigLayerSource::User { file, .. } => Some(file.clone()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let paths = if user_config_paths.is_empty() {
|
|
vec![
|
|
state
|
|
.session_configuration
|
|
.codex_home
|
|
.join(CONFIG_TOML_FILE),
|
|
]
|
|
} else {
|
|
user_config_paths
|
|
};
|
|
(config, paths)
|
|
};
|
|
|
|
let mut reloaded_user_configs = Vec::with_capacity(config_toml_paths.len());
|
|
for config_toml_path in config_toml_paths {
|
|
let user_config = match std::fs::read_to_string(&config_toml_path) {
|
|
Ok(contents) => match toml::from_str::<toml::Value>(&contents) {
|
|
Ok(config) => config,
|
|
Err(err) => {
|
|
warn!("failed to parse user config while reloading layer: {err}");
|
|
return;
|
|
}
|
|
},
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
toml::Value::Table(Default::default())
|
|
}
|
|
Err(err) => {
|
|
warn!("failed to read user config while reloading layer: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let Some(config_dir) = config_toml_path.parent() else {
|
|
warn!("user config path has no parent directory");
|
|
return;
|
|
};
|
|
let user_config = match codex_config::loader::resolve_relative_paths_in_config_toml(
|
|
user_config,
|
|
&config_dir,
|
|
) {
|
|
Ok(config) => config,
|
|
Err(err) => {
|
|
warn!("failed to resolve paths while reloading user config: {err}");
|
|
return;
|
|
}
|
|
};
|
|
reloaded_user_configs.push((config_toml_path, user_config));
|
|
}
|
|
|
|
let mut next_config = expected_config.as_ref().clone();
|
|
for (config_toml_path, user_config) in reloaded_user_configs {
|
|
let config_layer_stack = match next_config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config)
|
|
{
|
|
Ok(config_layer_stack) => config_layer_stack,
|
|
Err(err) => {
|
|
warn!("failed to validate user config while reloading layer: {err}");
|
|
return;
|
|
}
|
|
};
|
|
next_config.config_layer_stack = config_layer_stack;
|
|
}
|
|
match self
|
|
.refresh_config(
|
|
expected_config,
|
|
next_config,
|
|
crate::config::RuntimeConfigRefresh::UserFiles,
|
|
)
|
|
.await
|
|
{
|
|
crate::ConfigRefreshOutcome::Stale => continue,
|
|
crate::ConfigRefreshOutcome::Published | crate::ConfigRefreshOutcome::Rejected => {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a terminal CodexErr before the app-server completion notification is reduced.
|
|
pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) {
|
|
self.services
|
|
.analytics_events_client
|
|
.track_turn_codex_error(TurnCodexErrorFact::from_codex_err(
|
|
self.thread_id.to_string(),
|
|
turn_context.sub_id.clone(),
|
|
error,
|
|
));
|
|
}
|
|
|
|
/// Returns the reviewer pool installed by the Guardian extension.
|
|
pub(crate) fn guardian_review_session(&self) -> Option<Arc<GuardianReviewSessionManager>> {
|
|
self.services
|
|
.thread_extension_data
|
|
.get::<GuardianReviewSessionManager>()
|
|
}
|
|
|
|
pub(crate) async fn is_private_guardian_reviewer(&self) -> bool {
|
|
self.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.trusted_guardian_reviewer
|
|
}
|
|
|
|
pub(crate) async fn emit_turn_started(&self, turn_context: &TurnContext, task_kind: TaskKind) {
|
|
let event = TurnStartedEvent {
|
|
turn_attribution: (task_kind == TaskKind::Regular).then(|| turn_context.attribution()),
|
|
turn_id: turn_context.sub_id.clone(),
|
|
root_turn_id: Some(turn_context.root_turn_id()),
|
|
trace_id: turn_context.trace_id.clone(),
|
|
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
|
model_context_window: turn_context.model_context_window(),
|
|
collaboration_mode_kind: turn_context.mode(),
|
|
};
|
|
self.send_event(turn_context, EventMsg::TurnStarted(event))
|
|
.await;
|
|
}
|
|
|
|
/// Persist the event to rollout and send it to clients.
|
|
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
|
|
if let Some((item_id, started_at_ms)) = msg.command_approval_start() {
|
|
// The client can see the command before execution. Keep its first start
|
|
// for the canonical lifecycle events that will be saved in history.
|
|
turn_context
|
|
.turn_timing_state
|
|
.record_item_started(item_id.to_owned(), started_at_ms)
|
|
.await;
|
|
}
|
|
let legacy_source = msg.clone();
|
|
if let EventMsg::Error(error) = &legacy_source
|
|
&& error
|
|
.codex_error_info
|
|
.as_ref()
|
|
.is_some_and(CodexErrorInfo::affects_turn_status)
|
|
{
|
|
turn_context
|
|
.terminal_error
|
|
.lock()
|
|
.await
|
|
.replace(error.clone());
|
|
}
|
|
self.services
|
|
.rollout_thread_trace
|
|
.record_codex_turn_event(&turn_context.sub_id, &legacy_source);
|
|
self.services
|
|
.rollout_thread_trace
|
|
.record_tool_call_event(turn_context.sub_id.clone(), &legacy_source);
|
|
let event = Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg,
|
|
};
|
|
// Private reviewers have no app-server listener; publicly resumed Guardian threads do.
|
|
if matches!(
|
|
&turn_context.session_source,
|
|
SessionSource::SubAgent(SubAgentSource::Other(name))
|
|
if name == crate::guardian::GUARDIAN_REVIEWER_NAME
|
|
) && self.services.analytics_events_client.is_enabled()
|
|
&& turn_context.parent_thread_id.is_some()
|
|
&& self
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.trusted_guardian_reviewer
|
|
{
|
|
self.services
|
|
.analytics_events_client
|
|
.track_guardian_session_event(self.thread_id, &event);
|
|
}
|
|
self.send_event_raw(event).await;
|
|
self.maybe_notify_parent_of_terminal_turn(turn_context, &legacy_source)
|
|
.await;
|
|
self.maybe_mirror_event_text_to_realtime(&legacy_source)
|
|
.await;
|
|
self.maybe_clear_realtime_handoff_for_event(&legacy_source)
|
|
.await;
|
|
|
|
let show_raw_agent_reasoning = self.show_raw_agent_reasoning();
|
|
for legacy in legacy_source.as_legacy_events(show_raw_agent_reasoning) {
|
|
self.services
|
|
.rollout_thread_trace
|
|
.record_tool_call_event(turn_context.sub_id.clone(), &legacy);
|
|
let legacy_event = Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg: legacy,
|
|
};
|
|
self.send_event_raw(legacy_event).await;
|
|
}
|
|
}
|
|
|
|
/// Forwards terminal turn events from spawned MultiAgentV2 children to their direct parent.
|
|
async fn maybe_notify_parent_of_terminal_turn(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
msg: &EventMsg,
|
|
) {
|
|
if turn_context.multi_agent_version != MultiAgentVersion::V2 {
|
|
return;
|
|
}
|
|
|
|
if !matches!(msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) {
|
|
return;
|
|
}
|
|
|
|
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
agent_path: Some(_),
|
|
..
|
|
}) = &turn_context.session_source
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let mut error_info = None;
|
|
let status = match turn_context.terminal_error.lock().await.take() {
|
|
Some(error) => {
|
|
let status = AgentStatus::Errored(error.message);
|
|
self.agent_status.send_replace(status.clone());
|
|
status
|
|
}
|
|
None => {
|
|
if let EventMsg::TurnAborted(event) = msg
|
|
&& event.reason == TurnAbortReason::Interrupted
|
|
&& let Some(error) = &event.error
|
|
&& error.codex_error_info == Some(CodexErrorInfo::TooManyDenials)
|
|
{
|
|
// Report the safety stop to the parent without changing the child's
|
|
// interrupted status or notifying for ordinary interruptions.
|
|
error_info = error.codex_error_info.clone();
|
|
AgentStatus::Errored(error.message.clone())
|
|
} else {
|
|
let Some(status) = agent_status_from_event(msg) else {
|
|
return;
|
|
};
|
|
status
|
|
}
|
|
}
|
|
};
|
|
if !is_final(&status) {
|
|
return;
|
|
}
|
|
|
|
self.services
|
|
.agent_control
|
|
.turn_finished(
|
|
AgentTurnOutcome {
|
|
thread_id: self.thread_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
source: turn_context.session_source.clone(),
|
|
parent_turn_id: turn_context.turn_metadata_state.parent_turn_id(),
|
|
initiating_agent_path: turn_context
|
|
.turn_metadata_state
|
|
.initiating_agent_path()
|
|
.cloned(),
|
|
status,
|
|
error_info,
|
|
},
|
|
&self.services.rollout_thread_trace,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
|
|
if self.conversation.running_state().await.is_none() {
|
|
return;
|
|
}
|
|
match msg {
|
|
EventMsg::ItemStarted(event) => {
|
|
if let TurnItem::AgentMessage(item) = &event.item {
|
|
self.conversation
|
|
.register_handoff_stream_item(
|
|
item.id.clone(),
|
|
item.phase.clone(),
|
|
agent_message_text(item),
|
|
)
|
|
.await;
|
|
}
|
|
return;
|
|
}
|
|
EventMsg::AgentMessageContentDelta(event) => {
|
|
if let Err(err) = self
|
|
.conversation
|
|
.stream_handoff_delta(&event.item_id, event.delta.clone())
|
|
.await
|
|
{
|
|
debug!("failed to stream event text to realtime conversation: {err}");
|
|
}
|
|
return;
|
|
}
|
|
EventMsg::ItemCompleted(event) => {
|
|
if let TurnItem::AgentMessage(item) = &event.item
|
|
&& self.conversation.finish_handoff_stream_item(&item.id).await
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
let result = match realtime_text_for_event(msg) {
|
|
Some(RealtimeEventText::Handoff(text, phase)) => {
|
|
self.conversation.handoff_out(text, phase).await
|
|
}
|
|
Some(RealtimeEventText::QuietReasoning(text)) => {
|
|
self.conversation.send_reasoning_status(&text).await
|
|
}
|
|
None => return,
|
|
};
|
|
if let Err(err) = result {
|
|
debug!("failed to mirror event text to realtime conversation: {err}");
|
|
}
|
|
}
|
|
|
|
async fn maybe_clear_realtime_handoff_for_event(&self, msg: &EventMsg) {
|
|
if !matches!(msg, EventMsg::TurnComplete(_)) {
|
|
return;
|
|
}
|
|
if let Err(err) = self.conversation.handoff_complete().await {
|
|
debug!("failed to finalize realtime handoff output: {err}");
|
|
}
|
|
self.conversation.clear_active_handoff().await;
|
|
}
|
|
|
|
pub(crate) async fn send_event_raw(&self, event: Event) {
|
|
self.send_event_raw_with_persistence(event, /*persist*/ true)
|
|
.await;
|
|
}
|
|
|
|
/// Delivers an event without creating a local rollout for a thread that has not materialized.
|
|
pub(crate) async fn send_event_raw_without_materializing_rollout(&self, event: Event) {
|
|
let persist = match self.current_rollout_path().await {
|
|
Ok(Some(path)) => codex_rollout::existing_rollout_path(&path).await.is_some(),
|
|
Ok(None) => true,
|
|
Err(err) => {
|
|
warn!("failed to check whether thread persistence is materialized: {err}");
|
|
true
|
|
}
|
|
};
|
|
self.send_event_raw_with_persistence(event, persist).await;
|
|
}
|
|
|
|
async fn send_event_raw_with_persistence(&self, event: Event, persist: bool) {
|
|
let flush_guardian_completion = persist
|
|
&& matches!(event.msg, EventMsg::TurnComplete(_))
|
|
&& self.is_private_guardian_reviewer().await;
|
|
// Keep realtime reduction, canonical append, and delivery in the same order.
|
|
// This lock must not acquire SessionState or ActiveTurn: event producers can
|
|
// already hold those locks. Host presentation policies are synchronous.
|
|
let mut realtime_history = match &self.realtime_history {
|
|
Some(history) => {
|
|
let history = history.lock().await;
|
|
history.should_observe(&event.msg).then_some(history)
|
|
}
|
|
None => None,
|
|
};
|
|
self.services.mcp_runtime.observe_event(&event.msg);
|
|
let (before_event, after_event) = match realtime_history.as_mut() {
|
|
Some(history) => {
|
|
let effects = history.observe(&event.msg);
|
|
match effects.order {
|
|
RealtimeEventOrder::BeforeEvent => (Some(effects), None),
|
|
RealtimeEventOrder::AfterEvent => (None, Some(effects)),
|
|
}
|
|
}
|
|
None => (None, None),
|
|
};
|
|
if let Some(effects) = before_event
|
|
&& let Err(error) = self.send_realtime_history_effects(&event.id, effects).await
|
|
{
|
|
warn!("failed to persist realtime history: {error}");
|
|
}
|
|
// Persist the event into rollout storage; the store applies its persistence policy.
|
|
if persist {
|
|
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
|
|
self.persist_rollout_items(&rollout_items).await;
|
|
}
|
|
self.services
|
|
.rollout_thread_trace
|
|
.record_protocol_event(&event.msg);
|
|
if let Some(effects) = after_event
|
|
&& let Err(error) = self.send_realtime_history_effects(&event.id, effects).await
|
|
{
|
|
warn!("failed to persist realtime history: {error}");
|
|
}
|
|
// Save the completed review and its terminal event together before the parent sees
|
|
// the decision. Turn completion skips its separate before/after barriers for this case.
|
|
if flush_guardian_completion && let Err(err) = self.flush_rollout().await {
|
|
warn!("failed to flush completed Guardian review: {err}");
|
|
}
|
|
self.deliver_event_raw(event).await;
|
|
}
|
|
|
|
async fn deliver_event_raw(&self, event: Event) {
|
|
if let Some(parent_tx) = self.parent_observer_event_tx.get() {
|
|
let forwarded = match &event.msg {
|
|
EventMsg::RawResponseItem(item) => {
|
|
Some(PrivateSessionEventPayload::RecordedItem(item.clone()))
|
|
}
|
|
EventMsg::TurnAborted(aborted) => {
|
|
Some(PrivateSessionEventPayload::TurnAborted(aborted.clone()))
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(forwarded) = forwarded {
|
|
// Queue observer events before the private consumer can finish the review.
|
|
// They bypass the parent's history and retain the reviewer's identity.
|
|
let _ = parent_tx
|
|
.send(Event {
|
|
id: event.id.clone(),
|
|
msg: EventMsg::PrivateSessionEvent(PrivateSessionEvent {
|
|
thread_id: self.thread_id,
|
|
event: forwarded,
|
|
}),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
// Record the last known agent status.
|
|
if let Some(status) = agent_status_from_event(&event.msg) {
|
|
self.agent_status.send_replace(status);
|
|
}
|
|
if let Err(e) = self.tx_event.send(event).await {
|
|
debug!("dropping event because channel is closed: {e}");
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) {
|
|
let started_at_ms = turn_context
|
|
.turn_timing_state
|
|
.record_item_started(item.id(), now_unix_timestamp_ms())
|
|
.await;
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
thread_id: self.thread_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
item: item.clone(),
|
|
started_at_ms,
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn emit_turn_item_completed(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
item: TurnItem,
|
|
) {
|
|
record_turn_ttfm_metric(turn_context, &item).await;
|
|
for contributor in self.services.extensions.turn_lifecycle_contributors() {
|
|
contributor
|
|
.on_item_completed(
|
|
&self.services.thread_extension_data,
|
|
turn_context.extension_data.as_ref(),
|
|
&item,
|
|
)
|
|
.await;
|
|
}
|
|
let completed_at_ms = now_unix_timestamp_ms();
|
|
let item_id = item.id();
|
|
let started_at_ms = turn_context
|
|
.turn_timing_state
|
|
.take_item_started(&item_id)
|
|
.await
|
|
.unwrap_or_else(|| {
|
|
warn!(
|
|
thread_id = %self.thread_id,
|
|
turn_id = %turn_context.sub_id,
|
|
item_id = %item_id,
|
|
"item completed without a recorded start timestamp"
|
|
);
|
|
completed_at_ms
|
|
});
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id: self.thread_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
item,
|
|
started_at_ms: Some(started_at_ms),
|
|
completed_at_ms,
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Adds an execpolicy amendment to both the in-memory and on-disk policies so future
|
|
/// commands can use the newly approved prefix.
|
|
pub(crate) async fn persist_execpolicy_amendment(
|
|
&self,
|
|
amendment: &ExecPolicyAmendment,
|
|
) -> Result<(), ExecPolicyUpdateError> {
|
|
let codex_home = self
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.codex_home()
|
|
.clone();
|
|
|
|
self.services
|
|
.exec_policy
|
|
.append_amendment_and_update(&codex_home, amendment)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn turn_context_for_sub_id(&self, sub_id: &str) -> Option<Arc<TurnContext>> {
|
|
let active = self.active_turn.lock().await;
|
|
active
|
|
.as_ref()
|
|
.and_then(|turn| turn.task.as_ref())
|
|
.filter(|task| task.turn_context.sub_id == sub_id)
|
|
.map(|task| Arc::clone(&task.turn_context))
|
|
}
|
|
|
|
async fn active_turn_context_and_cancellation_token(
|
|
&self,
|
|
) -> Option<(Arc<TurnContext>, CancellationToken)> {
|
|
let active = self.active_turn.lock().await;
|
|
let task = active.as_ref()?.task.as_ref()?;
|
|
Some((
|
|
Arc::clone(&task.turn_context),
|
|
task.cancellation_token.child_token(),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn persist_network_policy_amendment(
|
|
&self,
|
|
amendment: &NetworkPolicyAmendment,
|
|
network_approval_context: &NetworkApprovalContext,
|
|
on_policy_applied: impl FnOnce() + Send,
|
|
) -> anyhow::Result<()> {
|
|
let _refresh_guard = self
|
|
.managed_network_proxy_refresh_lock
|
|
.acquire()
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("managed network proxy refresh semaphore closed"))?;
|
|
let host =
|
|
Self::validated_network_policy_amendment_host(amendment, network_approval_context)?;
|
|
let codex_home = self
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.codex_home()
|
|
.clone();
|
|
let execpolicy_amendment =
|
|
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
|
|
let mut on_policy_applied = Some(on_policy_applied);
|
|
|
|
if let Some(started_network_proxy) = self.services.network_proxy.load_full() {
|
|
let proxy = started_network_proxy.proxy();
|
|
match amendment.action {
|
|
NetworkPolicyRuleAction::Allow => proxy
|
|
.add_allowed_domain(&host)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("failed to update runtime allowlist: {err}"))?,
|
|
NetworkPolicyRuleAction::Deny => proxy
|
|
.add_denied_domain(&host)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("failed to update runtime denylist: {err}"))?,
|
|
}
|
|
// Active enforcement changed successfully. Notify the owner before
|
|
// the next fallible await so cancellation cannot contradict it.
|
|
if let Some(on_policy_applied) = on_policy_applied.take() {
|
|
on_policy_applied();
|
|
}
|
|
}
|
|
|
|
self.services
|
|
.exec_policy
|
|
.append_network_rule_and_update(
|
|
&codex_home,
|
|
&host,
|
|
execpolicy_amendment.protocol,
|
|
execpolicy_amendment.decision,
|
|
Some(execpolicy_amendment.justification),
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
anyhow::anyhow!("failed to persist network policy amendment to execpolicy: {err}")
|
|
})?;
|
|
|
|
// Without a running proxy, persistence is the first effective policy change.
|
|
if let Some(on_policy_applied) = on_policy_applied {
|
|
on_policy_applied();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validated_network_policy_amendment_host(
|
|
amendment: &NetworkPolicyAmendment,
|
|
network_approval_context: &NetworkApprovalContext,
|
|
) -> anyhow::Result<String> {
|
|
let approved_host = normalize_host(&network_approval_context.host);
|
|
let amendment_host = normalize_host(&amendment.host);
|
|
if amendment_host != approved_host {
|
|
return Err(anyhow::anyhow!(
|
|
"network policy amendment host '{}' does not match approved host '{}'",
|
|
amendment.host,
|
|
network_approval_context.host
|
|
));
|
|
}
|
|
Ok(approved_host)
|
|
}
|
|
|
|
pub(crate) async fn record_network_policy_amendment_message(
|
|
&self,
|
|
sub_id: &str,
|
|
amendment: &NetworkPolicyAmendment,
|
|
) {
|
|
let message: ResponseItem = ContextualUserFragment::into(NetworkRuleSaved::new(amendment));
|
|
let turn_context = self.turn_context_for_sub_id(sub_id).await;
|
|
self.inject_no_new_turn(vec![message], turn_context.as_deref())
|
|
.await;
|
|
}
|
|
|
|
fn user_wait_timing_owner(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
call_id: Option<&str>,
|
|
) -> Option<Arc<codex_direct_tool_timing::ToolTimingGuard>> {
|
|
if !turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::CriticalPathTiming)
|
|
{
|
|
return None;
|
|
}
|
|
// Retain the invocation identity before request preparation can suspend.
|
|
call_id.and_then(|call_id| {
|
|
turn_context
|
|
.turn_timing_state
|
|
.direct_tool_timings
|
|
.owner_for_turn(call_id, &turn_context.sub_id)
|
|
})
|
|
}
|
|
|
|
fn start_user_wait_timing(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
owner: Option<&codex_direct_tool_timing::ToolTimingGuard>,
|
|
) -> Option<codex_direct_tool_timing::ToolBlockingTimingGuard> {
|
|
let timing = owner.and_then(codex_direct_tool_timing::ToolTimingGuard::start_user_wait);
|
|
if timing.is_none()
|
|
&& turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::CriticalPathTiming)
|
|
{
|
|
turn_context.turn_timing_state.invalidate_critical_path();
|
|
}
|
|
timing
|
|
}
|
|
|
|
/// Emit an exec approval request event and await the user's decision.
|
|
///
|
|
/// The request is keyed by `call_id` + `approval_id` so matching responses
|
|
/// are delivered to the correct in-flight turn. If the pending approval is
|
|
/// cleared before a response arrives, treat it as an abort so interrupted
|
|
/// turns do not continue on a synthetic denial.
|
|
///
|
|
/// Note that if `available_decisions` is `None`, then the other fields will
|
|
/// be used to derive the available decisions via
|
|
/// [ExecApprovalRequestEvent::default_available_decisions].
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn request_command_approval(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
kind: ExecApprovalKind,
|
|
model_context: ModelInvocationContext,
|
|
call_id: String,
|
|
timing_call_id: Option<&str>,
|
|
approval_id: Option<String>,
|
|
environment_id: Option<String>,
|
|
command: Vec<String>,
|
|
cwd: PathUri,
|
|
reason: Option<String>,
|
|
network_approval_context: Option<NetworkApprovalContext>,
|
|
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
|
additional_permissions: Option<AdditionalPermissionProfile>,
|
|
available_decisions: Option<Vec<ReviewDecision>>,
|
|
plugin_attribution_override: Option<PluginCommandAttribution>,
|
|
) -> ReviewDecision {
|
|
let user_wait_owner = self.user_wait_timing_owner(turn_context, timing_call_id);
|
|
let _elicitation = self.services.elicitations.register();
|
|
// command-level approvals use `call_id`.
|
|
// `approval_id` identifies subcommand callbacks and stdin writes.
|
|
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
|
|
// Add the tx_approve callback to the map before sending the request.
|
|
let (tx_approve, rx_approve) = oneshot::channel();
|
|
let prev_entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if prev_entry.is_some() {
|
|
warn!("Overwriting existing pending approval for call_id: {effective_approval_id}");
|
|
}
|
|
|
|
let parsed_cmd = parse_command(&command);
|
|
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
|
|
vec![
|
|
NetworkPolicyAmendment {
|
|
host: context.host.clone(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
},
|
|
NetworkPolicyAmendment {
|
|
host: context.host.clone(),
|
|
action: NetworkPolicyRuleAction::Deny,
|
|
},
|
|
]
|
|
});
|
|
let available_decisions = available_decisions.unwrap_or_else(|| {
|
|
ExecApprovalRequestEvent::default_available_decisions(
|
|
network_approval_context.as_ref(),
|
|
proposed_execpolicy_amendment.as_ref(),
|
|
proposed_network_policy_amendments.as_deref(),
|
|
additional_permissions.as_ref(),
|
|
)
|
|
});
|
|
let plugin_attribution = plugin_attribution_override.or_else(|| {
|
|
cwd.to_abs_path()
|
|
.ok()
|
|
.and_then(|cwd| turn_context.plugin_attribution_for_command(&command, &cwd))
|
|
});
|
|
let (plugin_id, script_path) = plugin_attribution
|
|
.as_ref()
|
|
.map(PluginCommandAttribution::serialized_fields)
|
|
.unzip();
|
|
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
|
model_context: Some(model_context),
|
|
kind,
|
|
call_id,
|
|
plugin_id,
|
|
script_path,
|
|
approval_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
environment_id,
|
|
started_at_ms: now_unix_timestamp_ms(),
|
|
command,
|
|
cwd: cwd.into(),
|
|
reason,
|
|
network_approval_context,
|
|
proposed_execpolicy_amendment,
|
|
proposed_network_policy_amendments,
|
|
additional_permissions,
|
|
available_decisions: Some(available_decisions),
|
|
parsed_cmd,
|
|
});
|
|
let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref());
|
|
self.send_event(turn_context, event).await;
|
|
rx_approve.await.unwrap_or(ReviewDecision::Abort)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn request_patch_approval(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
call_id: String,
|
|
changes: HashMap<PathBuf, FileChange>,
|
|
reason: Option<String>,
|
|
grant_root: Option<PathBuf>,
|
|
) -> ReviewDecision {
|
|
let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id));
|
|
let _elicitation = self.services.elicitations.register();
|
|
// Add the tx_approve callback to the map before sending the request.
|
|
let (tx_approve, rx_approve) = oneshot::channel();
|
|
let approval_id = call_id.clone();
|
|
let prev_entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.insert_pending_approval(approval_id.clone(), tx_approve)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if prev_entry.is_some() {
|
|
warn!("Overwriting existing pending approval for call_id: {approval_id}");
|
|
}
|
|
|
|
let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
|
call_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
started_at_ms: now_unix_timestamp_ms(),
|
|
changes,
|
|
reason,
|
|
grant_root,
|
|
});
|
|
let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref());
|
|
self.send_event(turn_context, event).await;
|
|
rx_approve.await.unwrap_or(ReviewDecision::Abort)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub(crate) async fn request_permissions_for_environment(
|
|
self: &Arc<Self>,
|
|
step_context: &Arc<StepContext>,
|
|
call_id: String,
|
|
request: PreparedPermissionRequest,
|
|
cancellation_token: CancellationToken,
|
|
) -> Option<RequestPermissionsResponse> {
|
|
let turn_context = &step_context.turn;
|
|
let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id));
|
|
let approval_policy = step_context.settings.approval_policy();
|
|
let Some(environment) = step_context
|
|
.environments
|
|
.turn_environments()
|
|
.find(|candidate| candidate.selection.environment_id == request.environment_id)
|
|
.cloned()
|
|
else {
|
|
return Some(RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
});
|
|
};
|
|
let cwd = environment.cwd();
|
|
match approval_policy {
|
|
AskForApproval::Never => {
|
|
return Some(RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
});
|
|
}
|
|
AskForApproval::Granular(granular_config)
|
|
if !granular_config.allows_request_permissions() =>
|
|
{
|
|
return Some(RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
});
|
|
}
|
|
AskForApproval::OnRequest
|
|
| AskForApproval::UnlessTrusted
|
|
| AskForApproval::Granular(_) => {}
|
|
}
|
|
|
|
let action = request.approval_action(&call_id, &turn_context.sub_id);
|
|
let requested_permissions = request.permissions;
|
|
let sandbox_context = environment.sandbox_context(/*additional_permissions*/ None);
|
|
let context = sandbox_context.policy_context();
|
|
{
|
|
let approval_context = ApprovalContext {
|
|
review_context: crate::guardian::GuardianReviewContext::from(step_context),
|
|
cancellation_token: Some(cancellation_token.clone()),
|
|
call_id: call_id.clone(),
|
|
tool_name: ToolName::plain("request_permissions"),
|
|
strict_auto_review: false,
|
|
approval_reason: None,
|
|
retry_reason: None,
|
|
network_approval_context: None,
|
|
};
|
|
let decision = tokio::select! {
|
|
biased;
|
|
_ = cancellation_token.cancelled() => return None,
|
|
decision = self.request_guardian_approval(
|
|
action,
|
|
&approval_context,
|
|
) => decision,
|
|
};
|
|
if let Some(decision) = decision {
|
|
let (permissions, scope) = match decision {
|
|
ReviewDecision::Approved
|
|
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
|
| ReviewDecision::NetworkPolicyAmendment {
|
|
network_policy_amendment:
|
|
NetworkPolicyAmendment {
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
..
|
|
},
|
|
} => (requested_permissions.clone(), PermissionGrantScope::Turn),
|
|
ReviewDecision::ApprovedForSession => {
|
|
(requested_permissions.clone(), PermissionGrantScope::Session)
|
|
}
|
|
ReviewDecision::ApprovedMcpPolicyAmendment
|
|
| ReviewDecision::NetworkPolicyAmendment {
|
|
network_policy_amendment:
|
|
NetworkPolicyAmendment {
|
|
action: NetworkPolicyRuleAction::Deny,
|
|
..
|
|
},
|
|
}
|
|
| ReviewDecision::Abort
|
|
| ReviewDecision::Denied { .. }
|
|
| ReviewDecision::TimedOut => (
|
|
RequestPermissionProfile::default(),
|
|
PermissionGrantScope::Turn,
|
|
),
|
|
};
|
|
let response = RequestPermissionsResponse {
|
|
permissions,
|
|
scope,
|
|
strict_auto_review: false,
|
|
};
|
|
let response = Self::normalize_request_permissions_response(
|
|
requested_permissions,
|
|
response,
|
|
&context,
|
|
);
|
|
self.record_granted_request_permissions_for_turn(
|
|
&response,
|
|
&environment.selection.environment_id,
|
|
turn_context,
|
|
);
|
|
return Some(response);
|
|
}
|
|
}
|
|
|
|
let _elicitation = self.services.elicitations.register();
|
|
let (tx_response, rx_response) = oneshot::channel();
|
|
let prev_entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.insert_pending_request_permissions(
|
|
call_id.clone(),
|
|
PendingRequestPermissions {
|
|
tx_response,
|
|
requested_permissions: requested_permissions.clone(),
|
|
environment: environment.clone(),
|
|
turn_context: Arc::clone(turn_context),
|
|
},
|
|
)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if prev_entry.is_some() {
|
|
warn!("Overwriting existing pending request_permissions for call_id: {call_id}");
|
|
}
|
|
|
|
let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
|
|
call_id: call_id.clone(),
|
|
turn_id: turn_context.sub_id.clone(),
|
|
environment_id: Some(environment.selection.environment_id.clone()),
|
|
started_at_ms: now_unix_timestamp_ms(),
|
|
reason: request.reason,
|
|
permissions: requested_permissions,
|
|
cwd: Some(cwd.clone().into()),
|
|
});
|
|
let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref());
|
|
self.send_event(turn_context.as_ref(), event).await;
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancellation_token.cancelled() => {
|
|
drop(_user_wait);
|
|
let mut active = self.active_turn.lock().await;
|
|
if let Some(at) = active.as_mut() {
|
|
let mut ts = at.turn_state.lock().await;
|
|
let _ = ts.remove_pending_request_permissions(&call_id);
|
|
}
|
|
None
|
|
}
|
|
response = rx_response => response.ok(),
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub(crate) async fn request_user_input(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
call_id: String,
|
|
args: RequestUserInputArgs,
|
|
) -> Option<AcceptedUserInputResponse> {
|
|
let user_wait_owner = self.user_wait_timing_owner(turn_context, Some(&call_id));
|
|
let _elicitation = self.services.elicitations.register();
|
|
let sub_id = turn_context.sub_id.clone();
|
|
let (tx_response, rx_response) = oneshot::channel();
|
|
let event_id = sub_id.clone();
|
|
let prev_entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.insert_pending_user_input(sub_id, tx_response)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
if prev_entry.is_some() {
|
|
warn!("Overwriting existing pending user input for sub_id: {event_id}");
|
|
}
|
|
|
|
let event = EventMsg::RequestUserInput(RequestUserInputEvent {
|
|
call_id,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
questions: args.questions,
|
|
is_blocking: args.is_blocking,
|
|
auto_resolution_ms: args.auto_resolution_ms,
|
|
});
|
|
turn_context
|
|
.turn_metadata_state
|
|
.mark_user_input_requested_during_turn();
|
|
let _user_wait = self.start_user_wait_timing(turn_context, user_wait_owner.as_deref());
|
|
self.send_event(turn_context, event).await;
|
|
rx_response.await.ok()
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn notify_user_input_response(
|
|
&self,
|
|
sub_id: &str,
|
|
response: RequestUserInputResponse,
|
|
) {
|
|
let entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let sender = at.turn_state.lock().await.remove_pending_user_input(sub_id);
|
|
match sender {
|
|
Some(sender) => Some((sender, self.reserve_user_input_order().await)),
|
|
None => None,
|
|
}
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
match entry {
|
|
Some((tx_response, acceptance_order)) => {
|
|
tx_response
|
|
.send(AcceptedUserInputResponse {
|
|
response,
|
|
acceptance_order,
|
|
})
|
|
.ok();
|
|
}
|
|
None => {
|
|
warn!("No pending user input found for sub_id: {sub_id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn notify_request_permissions_response(
|
|
&self,
|
|
call_id: &str,
|
|
response: RequestPermissionsResponse,
|
|
) {
|
|
let entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.remove_pending_request_permissions(call_id)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
match entry {
|
|
Some(entry) => {
|
|
let sandbox_context = entry
|
|
.environment
|
|
.sandbox_context(/*additional_permissions*/ None);
|
|
let response = Self::normalize_request_permissions_response(
|
|
entry.requested_permissions,
|
|
response,
|
|
&sandbox_context.policy_context(),
|
|
);
|
|
self.record_granted_request_permissions_for_turn(
|
|
&response,
|
|
&entry.environment.selection.environment_id,
|
|
&entry.turn_context,
|
|
);
|
|
entry.tx_response.send(response).ok();
|
|
}
|
|
None => {
|
|
warn!("No pending request_permissions found for call_id: {call_id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_request_permissions_response(
|
|
requested_permissions: RequestPermissionProfile,
|
|
response: RequestPermissionsResponse,
|
|
context: &FileSystemSandboxPolicyContext<'_>,
|
|
) -> RequestPermissionsResponse {
|
|
if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) {
|
|
return RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
};
|
|
}
|
|
|
|
if response.permissions.is_empty() {
|
|
return response;
|
|
}
|
|
|
|
RequestPermissionsResponse {
|
|
permissions: intersect_permission_profiles_with_context(
|
|
requested_permissions.into(),
|
|
response.permissions.into(),
|
|
context,
|
|
)
|
|
.into(),
|
|
scope: response.scope,
|
|
strict_auto_review: response.strict_auto_review,
|
|
}
|
|
}
|
|
|
|
fn record_granted_request_permissions_for_turn(
|
|
&self,
|
|
response: &RequestPermissionsResponse,
|
|
environment_id: &str,
|
|
turn_context: &TurnContext,
|
|
) {
|
|
if response.permissions.is_empty() {
|
|
return;
|
|
}
|
|
match response.scope {
|
|
PermissionGrantScope::Turn => {
|
|
let permissions: AdditionalPermissionProfile = response.permissions.clone().into();
|
|
turn_context.record_granted_permissions(
|
|
environment_id,
|
|
permissions,
|
|
response.strict_auto_review,
|
|
);
|
|
}
|
|
PermissionGrantScope::Session => {
|
|
self.services.record_granted_permissions(
|
|
environment_id,
|
|
response.permissions.clone().into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn active_turn_context_and_strict_auto_review(
|
|
&self,
|
|
) -> Option<(
|
|
Arc<TurnContext>,
|
|
Arc<ResolvedStepSettings>,
|
|
TurnEnvironmentSnapshot,
|
|
bool,
|
|
)> {
|
|
let active = self.active_turn.lock().await;
|
|
let active = active.as_ref()?;
|
|
let task = active.task.as_ref()?;
|
|
let turn_context = Arc::clone(&task.turn_context);
|
|
let settings = turn_context.next_step_settings.load_full();
|
|
let strict_auto_review = turn_context.strict_auto_review_enabled();
|
|
let environments = self.services.turn_environments.snapshot_now();
|
|
Some((turn_context, settings, environments, strict_auto_review))
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
|
|
let entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.remove_pending_dynamic_tool(call_id)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
match entry {
|
|
Some(tx_response) => {
|
|
tx_response.send(response).ok();
|
|
}
|
|
None => {
|
|
warn!("No pending dynamic tool call found for call_id: {call_id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "active turn checks and turn state updates must remain atomic"
|
|
)]
|
|
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
|
let entry = {
|
|
let mut active = self.active_turn.lock().await;
|
|
match active.as_mut() {
|
|
Some(at) => {
|
|
let mut ts = at.turn_state.lock().await;
|
|
ts.remove_pending_approval(approval_id)
|
|
}
|
|
None => None,
|
|
}
|
|
};
|
|
match entry {
|
|
Some(tx_approve) => {
|
|
tx_approve.send(decision).ok();
|
|
}
|
|
None => {
|
|
warn!("No pending approval found for call_id: {approval_id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn response_item_create_time() -> serde_json::Number {
|
|
let create_time = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
serde_json::Number::from_f64(create_time.as_secs_f64())
|
|
.unwrap_or_else(|| serde_json::Number::from(create_time.as_secs()))
|
|
}
|
|
|
|
/// Adds the turn ID and creation time to newly created conversation items.
|
|
pub(crate) fn stamp_response_item_for_history(item: &mut ResponseItem, turn_id: &str) {
|
|
item.set_turn_id_if_missing(turn_id);
|
|
item.set_create_time_if_missing(Self::response_item_create_time());
|
|
}
|
|
|
|
/// Prepares media using the originating model and preserves existing item identity.
|
|
pub(crate) async fn prepare_conversation_items_for_history<'a>(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
items: &'a [ResponseItem],
|
|
) -> (Cow<'a, [ResponseItem]>, Vec<ImagePreparationMetadata>) {
|
|
let mut items = items.to_vec();
|
|
let image_preparation_mode =
|
|
if unified_image_budget_enabled(&turn_context.config.features, model_info) {
|
|
ImagePreparationMode::UnifiedBudget
|
|
} else {
|
|
ImagePreparationMode::DetailBased
|
|
};
|
|
let image_resize_notice_mode = if turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::ImageResizeNotice)
|
|
{
|
|
ImageResizeNoticeMode::Enabled
|
|
} else {
|
|
ImageResizeNoticeMode::Disabled
|
|
};
|
|
// Keep nested image-upload futures out of every caller's future frame.
|
|
let image_preparations = Box::pin(prepare_image_response_items(
|
|
&self.thread_id.to_string(),
|
|
turn_context.config.ephemeral,
|
|
&mut items,
|
|
image_preparation_mode,
|
|
image_resize_notice_mode,
|
|
self.services.image_store.as_ref(),
|
|
))
|
|
.await;
|
|
prepare_audio_response_items(&mut items);
|
|
// Most response items get their passthrough turn ID at the durable history boundary.
|
|
for item in &mut items {
|
|
Self::stamp_response_item_for_history(item, &turn_context.sub_id);
|
|
}
|
|
let items = Cow::Owned(items);
|
|
(
|
|
Self::assign_missing_response_item_ids(items),
|
|
image_preparations,
|
|
)
|
|
}
|
|
|
|
fn assign_missing_response_item_ids(items: Cow<'_, [ResponseItem]>) -> Cow<'_, [ResponseItem]> {
|
|
if items
|
|
.iter()
|
|
.all(|item| item.id().is_some_and(|id| !id.is_empty()))
|
|
{
|
|
return items;
|
|
}
|
|
let mut items = items;
|
|
for item in items.to_mut() {
|
|
Self::assign_missing_response_item_id(item);
|
|
}
|
|
items
|
|
}
|
|
|
|
pub(crate) fn assign_missing_response_item_id(item: &mut ResponseItem) {
|
|
if item.id().is_some_and(|id| !id.is_empty()) {
|
|
return;
|
|
}
|
|
let Some(prefix) = item.id_prefix() else {
|
|
return;
|
|
};
|
|
item.set_id(Some(ResponseItemId::new(prefix)));
|
|
}
|
|
|
|
fn assign_missing_rollout_response_item_ids(items: &mut [RolloutItem]) {
|
|
for item in items {
|
|
if let RolloutItem::ResponseItem(response_item) = item {
|
|
Self::assign_missing_response_item_id(&mut response_item.item);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn response_item_from_user_input(&self, input: Vec<UserInput>) -> ResponseItem {
|
|
self.response_item_from_user_input_with_image_positions(input, &mut HashMap::new())
|
|
}
|
|
|
|
fn response_item_from_user_input_with_image_positions(
|
|
&self,
|
|
input: Vec<UserInput>,
|
|
user_image_content_indices: &mut HashMap<usize, usize>,
|
|
) -> ResponseItem {
|
|
let mut item = ResponseItem::from(ResponseInputItem::from_user_input(
|
|
input,
|
|
LocalImagePreparation::Defer,
|
|
user_image_content_indices,
|
|
));
|
|
if let ResponseItem::Message {
|
|
content,
|
|
internal_chat_message_metadata_passthrough,
|
|
..
|
|
} = &mut item
|
|
{
|
|
let content_item_kinds = content
|
|
.iter()
|
|
.map(|content| {
|
|
ContentItemKind(
|
|
match content {
|
|
ContentItem::InputText { .. } | ContentItem::OutputText { .. } => {
|
|
"user.text"
|
|
}
|
|
ContentItem::InputImage { .. } => "user.image",
|
|
ContentItem::InputAudio { .. } => "user.audio",
|
|
}
|
|
.to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
*internal_chat_message_metadata_passthrough =
|
|
Some(InternalChatMessageMetadataPassthrough {
|
|
content_item_kinds: Some(content_item_kinds),
|
|
content_item_metadata: Some(vec![ContentItemMetadata::user(); content.len()]),
|
|
..Default::default()
|
|
});
|
|
}
|
|
item
|
|
}
|
|
|
|
/// Appends to history, persists the prepared items, then notifies raw-item observers.
|
|
/// Execution callers supply their captured model; standalone callers need no tool runtime.
|
|
#[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))]
|
|
pub(crate) async fn record_conversation_items(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
items: &[ResponseItem],
|
|
) {
|
|
let (items, image_preparations) = self
|
|
.prepare_conversation_items_for_history(turn_context, model_info, items)
|
|
.await;
|
|
let items = items
|
|
.into_owned()
|
|
.into_iter()
|
|
.map(ResponseItemEnvelope::new)
|
|
.collect();
|
|
self.record_prepared_conversation_items(
|
|
turn_context,
|
|
model_info,
|
|
items,
|
|
image_preparations,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_prepared_conversation_items(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
mut items: Vec<ResponseItemEnvelope>,
|
|
image_preparations: Vec<ImagePreparationMetadata>,
|
|
) {
|
|
// Save the originating history budget for replay.
|
|
// Preserve any existing tool-specific override.
|
|
let policy: codex_utils_output_truncation::TruncationPolicy =
|
|
model_info.truncation_policy.into();
|
|
for envelope in &mut items {
|
|
if matches!(
|
|
envelope.item,
|
|
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. }
|
|
) {
|
|
envelope
|
|
.metadata
|
|
.get_or_insert_default()
|
|
.history_truncation_token_limit
|
|
.get_or_insert_with(|| with_serialization_allowance(policy).token_budget());
|
|
}
|
|
}
|
|
// Last-N-turn forks retain a suffix starting at a user turn boundary. Repeat the
|
|
// cumulative checkpoint there so the suffix remains self-contained even when the fork
|
|
// happens mid-turn; dirty checkpoints preserve new sources between boundaries.
|
|
let force_mcp_checkpoint = items
|
|
.iter()
|
|
.any(|envelope| crate::context_manager::is_user_turn_boundary(&envelope.item));
|
|
let mcp_revision = self
|
|
.services
|
|
.executed_tool_calls
|
|
.mcp_attribution_checkpoint(force_mcp_checkpoint)
|
|
.and_then(|(attribution, revision)| {
|
|
let first_persisted = items
|
|
.iter()
|
|
.position(|envelope| should_persist_response_item(&envelope.item))?;
|
|
for (index, envelope) in items.iter_mut().enumerate() {
|
|
// Rollout batches may be partially written. Checkpoint the first persisted
|
|
// item, and repeat the checkpoint at turn boundaries retained by forks.
|
|
if index == first_persisted
|
|
|| crate::context_manager::is_user_turn_boundary(&envelope.item)
|
|
{
|
|
envelope.metadata.get_or_insert_default().mcp_attribution =
|
|
Some(attribution.clone());
|
|
}
|
|
}
|
|
Some(revision)
|
|
});
|
|
let response_items = items
|
|
.iter()
|
|
.map(|envelope| envelope.item.clone())
|
|
.collect::<Vec<_>>();
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state
|
|
.current_time_reminder
|
|
.note_recorded_items(&response_items);
|
|
let pending_orders = turn_context
|
|
.extension_data
|
|
.get::<retained_context::PendingAssistantMessageOrders>();
|
|
for envelope in &mut items {
|
|
if envelope
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.compaction_output)
|
|
{
|
|
continue;
|
|
}
|
|
if matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "assistant")
|
|
|| matches!(&envelope.item, ResponseItem::FunctionCall { .. })
|
|
|| crate::context::is_user_authorization_message(&envelope.item)
|
|
{
|
|
let message_order = pending_orders.as_ref().and_then(|orders| {
|
|
orders
|
|
.0
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.remove(envelope.item.id()?.as_str())
|
|
});
|
|
// Preserve input acceptance and source-message start order.
|
|
// Synthetic messages still receive their order here.
|
|
envelope
|
|
.metadata
|
|
.get_or_insert_default()
|
|
.user_input_order
|
|
.get_or_insert_with(|| {
|
|
message_order.unwrap_or_else(|| state.history.reserve_input_order())
|
|
});
|
|
}
|
|
}
|
|
state
|
|
.history
|
|
.record_annotated_items(&mut items, model_info.truncation_policy.into());
|
|
}
|
|
for image in image_preparations {
|
|
self.services
|
|
.analytics_events_client
|
|
.track_image_preparation(ImagePreparationFact {
|
|
turn_id: turn_context.sub_id.clone(),
|
|
metadata: image,
|
|
});
|
|
}
|
|
let settings_guard =
|
|
if force_mcp_checkpoint && self.reference_context_item().await.is_none() {
|
|
Some(thread_settings::acquire_persistence_lock(self).await)
|
|
} else {
|
|
None
|
|
};
|
|
let initial_settings = if settings_guard.is_some()
|
|
&& self.reference_context_item().await.is_none()
|
|
&& self.state.lock().await.take_initial_settings_pending()
|
|
{
|
|
Some(thread_settings::applied_event(self).await)
|
|
} else {
|
|
None
|
|
};
|
|
let rollout_items = initial_settings
|
|
.into_iter()
|
|
.map(RolloutItem::EventMsg)
|
|
.chain(items.into_iter().map(RolloutItem::ResponseItem))
|
|
.collect::<Vec<_>>();
|
|
let persisted = self.persist_rollout_items(&rollout_items).await;
|
|
drop(settings_guard);
|
|
if persisted && let Some(revision) = mcp_revision {
|
|
self.services
|
|
.executed_tool_calls
|
|
.mark_mcp_attribution_persisted(revision);
|
|
}
|
|
if turn_context.config.memories.disable_on_external_context
|
|
&& let Some(item) = response_items
|
|
.iter()
|
|
.find(|item| matches!(item, ResponseItem::FunctionCallOutput { call_id: None, .. }))
|
|
{
|
|
mark_thread_memory_mode_polluted_if_external_context(self, turn_context, item).await;
|
|
}
|
|
self.send_raw_response_items(turn_context, &response_items)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn record_step_world_state_if_changed(
|
|
&self,
|
|
step_context: &step_context::StepContext,
|
|
) -> CodexResult<Arc<WorldState>> {
|
|
let turn_context = step_context.turn.as_ref();
|
|
// Render model-visible state from the same step used to build and run tools.
|
|
let world_state = self
|
|
.build_world_state_for_step(step_context, /*new_window*/ false)
|
|
.await?;
|
|
let (world_state_snapshot, fragments, world_state_item) = self
|
|
.state
|
|
.lock()
|
|
.await
|
|
.history
|
|
.render_step_world_state(&world_state);
|
|
let items = crate::context_manager::updates::merge_world_state_updates(fragments);
|
|
if !items.is_empty() {
|
|
self.record_conversation_items(turn_context, &step_context.settings.model_info, &items)
|
|
.await;
|
|
}
|
|
|
|
self.state
|
|
.lock()
|
|
.await
|
|
.history
|
|
.set_world_state_baseline(world_state_snapshot);
|
|
// Record the patch after the context it describes is present in model history.
|
|
if let Some(world_state_item) = world_state_item {
|
|
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
|
|
.await;
|
|
}
|
|
Ok(Arc::new(world_state))
|
|
}
|
|
|
|
/// Retains the step captured for execution.
|
|
pub(crate) async fn set_last_known_step_context(&self, step_context: &Arc<StepContext>) {
|
|
let turn_state = {
|
|
let active_turn = self.active_turn.lock().await;
|
|
active_turn.as_ref().and_then(|active_turn| {
|
|
active_turn
|
|
.task
|
|
.as_ref()
|
|
.filter(|task| task.turn_context.sub_id == step_context.turn.sub_id)
|
|
.map(|_| Arc::clone(&active_turn.turn_state))
|
|
})
|
|
};
|
|
if let Some(turn_state) = turn_state {
|
|
turn_state.lock().await.last_known_step_context = Some(Arc::clone(step_context));
|
|
}
|
|
}
|
|
|
|
/// Captures one request-scoped view of dynamic state and retains it for the active turn.
|
|
///
|
|
/// This may refresh filesystem-derived state. Normal turns should call it only from
|
|
/// `run_turn` and pass the result down; standalone request or history boundaries may capture
|
|
/// their own step. Use speculative capture for a step that may not execute.
|
|
pub(crate) async fn capture_step_context(
|
|
self: &Arc<Self>,
|
|
turn_context: Arc<TurnContext>,
|
|
cancellation_token: &CancellationToken,
|
|
) -> CodexResult<Arc<StepContext>> {
|
|
self.capture_step_context_with_required_mcp_servers(
|
|
turn_context,
|
|
cancellation_token,
|
|
/*required_servers*/ &[],
|
|
/*required_plugins*/ &HashSet::new(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Captures and retains a step with the required MCP startup dependencies.
|
|
pub(crate) async fn capture_step_context_with_required_mcp_servers(
|
|
self: &Arc<Self>,
|
|
turn_context: Arc<TurnContext>,
|
|
cancellation_token: &CancellationToken,
|
|
required_servers: &[String],
|
|
required_plugins: &HashSet<String>,
|
|
) -> CodexResult<Arc<StepContext>> {
|
|
let step_context = self
|
|
.capture_step_context_inner(
|
|
turn_context,
|
|
cancellation_token,
|
|
required_servers,
|
|
required_plugins,
|
|
)
|
|
.await?;
|
|
self.set_last_known_step_context(&step_context).await;
|
|
Ok(step_context)
|
|
}
|
|
|
|
/// Prepares a candidate step without replacing the active turn's retained context.
|
|
/// The caller must retain it explicitly if it is selected for execution.
|
|
async fn capture_speculative_step_context(
|
|
self: &Arc<Self>,
|
|
turn_context: Arc<TurnContext>,
|
|
cancellation_token: &CancellationToken,
|
|
) -> CodexResult<Arc<StepContext>> {
|
|
self.capture_step_context_inner(
|
|
turn_context,
|
|
cancellation_token,
|
|
/*required_servers*/ &[],
|
|
/*required_plugins*/ &HashSet::new(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "step settings and both environment snapshots must be captured under active_turn then state"
|
|
)]
|
|
#[tracing::instrument(name = "step_context.capture", level = "info", skip_all)]
|
|
async fn capture_step_context_inner(
|
|
self: &Arc<Self>,
|
|
turn_context: Arc<TurnContext>,
|
|
cancellation_token: &CancellationToken,
|
|
required_servers: &[String],
|
|
required_plugins: &HashSet<String>,
|
|
) -> CodexResult<Arc<StepContext>> {
|
|
// Read the step's model and record its environments together so an update cannot split them.
|
|
// Match apply_turn_settings and activate_turn_environments: active_turn before state.
|
|
// Wait for executor startup below, after releasing both locks.
|
|
let (mut settings, environments, inherited_environments) = {
|
|
let _active = self
|
|
.active_turn
|
|
.lock()
|
|
.or_cancel(cancellation_token)
|
|
.await?;
|
|
let _state = self.state.lock().or_cancel(cancellation_token).await?;
|
|
(
|
|
turn_context.next_step_settings.load_full(),
|
|
self.services.turn_environments.snapshot(),
|
|
self.features
|
|
.enabled(Feature::SubagentThreadEnvironments)
|
|
.then(|| self.services.turn_environments.configured_snapshot_now()),
|
|
)
|
|
};
|
|
if matches!(
|
|
turn_context.session_source,
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
|
|
) {
|
|
let root_service_tier = self.services.agent_control.service_tier();
|
|
if settings.selected().service_tier != root_service_tier {
|
|
let mut selected = settings.selected().clone();
|
|
selected.service_tier = root_service_tier;
|
|
let mut inherited_settings = ResolvedStepSettings::new(
|
|
Arc::new(selected),
|
|
Arc::clone(&settings.model_info),
|
|
&self.features,
|
|
);
|
|
inherited_settings.mcp_approvals_reviewer_override =
|
|
settings.mcp_approvals_reviewer_override;
|
|
settings = Arc::new(inherited_settings);
|
|
}
|
|
}
|
|
let dynamic_tools = self.dynamic_tools().await;
|
|
let token_budget = token_budget::resolve_token_budget(
|
|
turn_context.configured_token_budget.as_ref(),
|
|
turn_context.use_model_token_budget_defaults,
|
|
settings.model_info.as_ref(),
|
|
);
|
|
let session_telemetry = settings.telemetry(&turn_context.session_telemetry);
|
|
let environments = environments.or_cancel(cancellation_token).await?;
|
|
let inherited_environments = inherited_environments.unwrap_or_else(|| environments.clone());
|
|
// Keep both preparation futures off caller stacks while they are live together.
|
|
let load_agents_md = Box::pin(async {
|
|
let (loaded_agents_md, warnings) = self
|
|
.services
|
|
.agents_md_manager
|
|
.refresh(&turn_context.config, &environments)
|
|
.or_cancel(cancellation_token)
|
|
.await?;
|
|
self.emit_instruction_warnings(warnings).await;
|
|
loaded_agents_md
|
|
});
|
|
let prepare_tools = Box::pin(async {
|
|
let selected_capability_roots = self
|
|
.resolve_selected_capability_roots_for_step(&environments)
|
|
.await;
|
|
let ready_selected_capability_roots =
|
|
Self::ready_selected_capability_roots(&selected_capability_roots);
|
|
let (executor_capability_discovery_v2, inventory_changed) = self
|
|
.executor_capability_discovery_for_step_v2(&turn_context.config, &environments)
|
|
.await;
|
|
let v2_snapshots = executor_capability_discovery_v2.as_ref();
|
|
let (executor_capability_discovery, discovery_recovered) = if v2_snapshots.is_some() {
|
|
(None, false)
|
|
} else {
|
|
self.executor_capability_discovery_for_step(
|
|
&turn_context.config,
|
|
&selected_capability_roots,
|
|
&environments,
|
|
executor_capability_discovery_v2.as_deref(),
|
|
)
|
|
.await
|
|
};
|
|
if inventory_changed || discovery_recovered {
|
|
self.mark_mcp_runtime_dirty();
|
|
}
|
|
let extension_data =
|
|
codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
|
|
extension_data.insert_arc(Arc::clone(&settings.model_info));
|
|
if let Some(messages) = settings
|
|
.model_info
|
|
.model_messages
|
|
.as_ref()
|
|
.and_then(|messages| messages.tools.as_ref())
|
|
.and_then(|tools| tools.multi_agent.as_ref())
|
|
{
|
|
extension_data.insert(messages.clone());
|
|
}
|
|
let model_messages = ResolvedModelMessages::from_model(&settings.model_info);
|
|
if let Some(messages) = model_messages.history() {
|
|
extension_data.insert(messages.clone());
|
|
}
|
|
if let Some(messages) = model_messages.notes() {
|
|
extension_data.insert(messages.clone());
|
|
}
|
|
let (mcp, prepared_recommendations) = tokio::join!(
|
|
// MCP refresh can be large; keep it off the sampling request's stack.
|
|
Box::pin(self.mcp_runtime_for_step(
|
|
turn_context.as_ref(),
|
|
&selected_capability_roots,
|
|
required_servers,
|
|
required_plugins,
|
|
)),
|
|
turn::prepare_tool_recommendations(self.as_ref(), turn_context.as_ref()),
|
|
);
|
|
extension_data.insert(selected_capability_roots);
|
|
// A step keeps the plugins from the environments it captured, even if shared MCP
|
|
// moves on to another environment. Its skill tools and the model use this same copy.
|
|
// Policy follows this step's MCP binding, which may have refreshed mid-turn.
|
|
let selected_plugins = self
|
|
.services
|
|
.mcp_manager
|
|
.selected_plugins_for_step(
|
|
codex_extension_api::McpServerContributionContext::for_step(
|
|
turn_context.config.as_ref(),
|
|
&self.services.mcp_thread_init,
|
|
&self.services.thread_extension_data,
|
|
&turn_context.originator,
|
|
&ready_selected_capability_roots,
|
|
executor_capability_discovery.as_deref(),
|
|
executor_capability_discovery_v2.as_deref(),
|
|
)
|
|
.with_session_source(&turn_context.session_source)
|
|
.with_selected_environments(&environments.all_selections()),
|
|
&mcp.config().plugins,
|
|
&turn_context.disabled_plugin_ids,
|
|
)
|
|
.await;
|
|
for contributor in self.services.extensions.tool_contributors() {
|
|
contributor
|
|
.prepare_for_step(codex_extension_api::StepPreparationInput {
|
|
turn_id: &turn_context.sub_id,
|
|
ready_selected_capability_roots: &ready_selected_capability_roots,
|
|
executor_capability_discovery: executor_capability_discovery.as_deref(),
|
|
executor_capability_discovery_v2: executor_capability_discovery_v2
|
|
.as_deref(),
|
|
selected_plugins: &selected_plugins,
|
|
session_store: &self.services.session_extension_data,
|
|
thread_store: &self.services.thread_extension_data,
|
|
turn_store: turn_context.extension_data.as_ref(),
|
|
step_store: &extension_data,
|
|
})
|
|
.or_cancel(cancellation_token)
|
|
.await?;
|
|
}
|
|
let executor_hook_sources = crate::hook_runtime::prepare_executor_hook_sources(
|
|
&mcp,
|
|
&selected_plugins,
|
|
executor_capability_discovery.as_deref(),
|
|
executor_capability_discovery_v2.as_deref(),
|
|
);
|
|
extension_data.insert(selected_plugins);
|
|
let tool_router = turn::built_tools(
|
|
self.as_ref(),
|
|
turn_context.as_ref(),
|
|
&settings.model_info,
|
|
&environments,
|
|
&mcp,
|
|
&extension_data,
|
|
prepared_recommendations,
|
|
&dynamic_tools,
|
|
)
|
|
.await?;
|
|
Ok::<_, CodexErr>((executor_hook_sources, mcp, tool_router, extension_data))
|
|
});
|
|
// Returned warnings must finish delivery even if tools fail or preparation is cancelled.
|
|
let (loaded_agents_md, prepared_tools) =
|
|
tokio::join!(load_agents_md, prepare_tools.or_cancel(cancellation_token));
|
|
let loaded_agents_md = loaded_agents_md?;
|
|
if cancellation_token.is_cancelled() {
|
|
return Err(CodexErr::TurnAborted);
|
|
}
|
|
let (executor_hook_sources, mcp, tool_router, extension_data) = prepared_tools??;
|
|
Ok(Arc::new(StepContext {
|
|
preempt: turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::InstantInterrupt)
|
|
.then(CancellationToken::new),
|
|
realtime: self.conversation.snapshot().await,
|
|
settings,
|
|
token_budget,
|
|
session_telemetry,
|
|
turn: turn_context,
|
|
extension_data,
|
|
environments,
|
|
inherited_environments,
|
|
executor_hook_sources,
|
|
mcp,
|
|
tool_router,
|
|
loaded_agents_md,
|
|
}))
|
|
}
|
|
|
|
pub(crate) async fn record_inter_agent_communication(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
communication: impl Into<crate::PendingAgentMessage>,
|
|
) {
|
|
let communication = communication.into();
|
|
// Agent-message dependencies are not captured by direct-tool timing.
|
|
if turn_context
|
|
.config
|
|
.features
|
|
.enabled(Feature::CriticalPathTiming)
|
|
{
|
|
turn_context.turn_timing_state.invalidate_critical_path();
|
|
}
|
|
let mut response_item = communication.to_model_input_item();
|
|
if let ResponseItem::AgentMessage {
|
|
content,
|
|
internal_chat_message_metadata_passthrough,
|
|
..
|
|
} = &mut response_item
|
|
{
|
|
internal_chat_message_metadata_passthrough
|
|
.get_or_insert_default()
|
|
.content_item_metadata
|
|
.get_or_insert_with(|| {
|
|
vec![
|
|
ContentItemMetadata::tool(Some(
|
|
turn_context
|
|
.config
|
|
.multi_agent_v2
|
|
.tool_namespace
|
|
.clone()
|
|
.map(ContentItemNamespace::from)
|
|
.unwrap_or(ContentItemNamespace::Functions),
|
|
));
|
|
content.len()
|
|
]
|
|
});
|
|
}
|
|
let (items, _) = self
|
|
.prepare_conversation_items_for_history(
|
|
turn_context,
|
|
model_info,
|
|
std::slice::from_ref(&response_item),
|
|
)
|
|
.await;
|
|
let items = items.as_ref();
|
|
let mut response_item = ResponseItemEnvelope::new(items[0].clone());
|
|
// A send confirmed after the pending snapshot must not reach the rollout
|
|
// before this boundary; older readers assign deliveries by physical order.
|
|
let boundary = self
|
|
.code_mode_message_tasks
|
|
.communication_boundary
|
|
.acquire()
|
|
.await
|
|
.unwrap_or_else(|_| unreachable!("communication boundary remains open"));
|
|
// Older readers assign delivered assistant messages to the next physical
|
|
// communication boundary, so persist earlier confirmed sends first.
|
|
let (order, pending) = {
|
|
let mut state = self.state.lock().await;
|
|
(
|
|
state.history.reserve_input_order(),
|
|
self.pending_code_mode_message_recordings(),
|
|
)
|
|
};
|
|
response_item
|
|
.metadata
|
|
.get_or_insert_default()
|
|
.user_input_order = Some(order);
|
|
for mut recording in pending {
|
|
let _ = recording.changed().await;
|
|
}
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.current_time_reminder.note_recorded_items(items);
|
|
state.history.record_annotated_items(
|
|
std::slice::from_mut(&mut response_item),
|
|
model_info.truncation_policy.into(),
|
|
);
|
|
}
|
|
if let Some(delivery) = &communication.delivery {
|
|
delivery.consumed(self.thread_id);
|
|
}
|
|
self.persist_rollout_items(&[
|
|
RolloutItem::InterAgentCommunicationMetadata {
|
|
trigger_turn: communication.trigger_turn,
|
|
},
|
|
RolloutItem::ResponseItem(response_item),
|
|
])
|
|
.await;
|
|
drop(boundary);
|
|
self.send_raw_response_items(turn_context, items).await;
|
|
}
|
|
|
|
async fn maybe_warn_on_server_model_mismatch(
|
|
self: &Arc<Self>,
|
|
step_context: &StepContext,
|
|
server_model: String,
|
|
) -> bool {
|
|
let turn_context = &step_context.turn;
|
|
let requested_model = step_context.settings.model_info.slug.clone();
|
|
let server_model_normalized = server_model.to_ascii_lowercase();
|
|
let requested_model_normalized = requested_model.to_ascii_lowercase();
|
|
if server_model_normalized == requested_model_normalized {
|
|
info!("server reported model {server_model} (matches requested model)");
|
|
return false;
|
|
}
|
|
|
|
warn!("server reported model {server_model} while requested model was {requested_model}");
|
|
|
|
let warning_message = format!(
|
|
"Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: {CYBER_VERIFY_URL} or learn more: {CYBER_SAFETY_URL}"
|
|
);
|
|
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::ModelReroute(ModelRerouteEvent {
|
|
from_model: requested_model.clone(),
|
|
to_model: server_model.clone(),
|
|
reason: ModelRerouteReason::HighRiskCyberActivity,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::Warning(WarningEvent {
|
|
message: warning_message.clone(),
|
|
}),
|
|
)
|
|
.await;
|
|
true
|
|
}
|
|
|
|
pub(crate) async fn emit_model_verification(
|
|
self: &Arc<Self>,
|
|
turn_context: &Arc<TurnContext>,
|
|
verifications: Vec<ModelVerification>,
|
|
) {
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::ModelVerification(ModelVerificationEvent { verifications }),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn emit_turn_moderation_metadata(
|
|
self: &Arc<Self>,
|
|
turn_context: &Arc<TurnContext>,
|
|
metadata: TurnModerationMetadataEvent,
|
|
) {
|
|
self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata))
|
|
.await;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn replace_history(
|
|
&self,
|
|
items: Vec<ResponseItem>,
|
|
reference_context_item: Option<TurnContextItem>,
|
|
) {
|
|
let mut state = self.state.lock().await;
|
|
state.replace_history(items, reference_context_item);
|
|
}
|
|
|
|
pub(crate) async fn replace_compacted_history<'a>(
|
|
&self,
|
|
mut items: Vec<ResponseItemEnvelope>,
|
|
reference_context_item: TurnContextItem,
|
|
mut world_state_baseline: WorldStateSnapshot,
|
|
installation: impl Into<CompactedHistoryInstallation<'a>>,
|
|
) -> bool {
|
|
let installation = installation.into();
|
|
let compaction_model_hash = match &installation {
|
|
CompactedHistoryInstallation::Complete(metadata) => &metadata.compaction_model_hash,
|
|
CompactedHistoryInstallation::Prefix { model_info, .. } => &model_info.comp_hash,
|
|
};
|
|
let mcp_revision = self
|
|
.services
|
|
.executed_tool_calls
|
|
.mcp_attribution_checkpoint(/*force*/ true)
|
|
.and_then(|(attribution, revision)| {
|
|
items.last_mut().map(|envelope| {
|
|
envelope.metadata.get_or_insert_default().mcp_attribution = Some(attribution);
|
|
revision
|
|
})
|
|
});
|
|
if let Some(checkpoint) = items.iter_mut().rev().find(|envelope| {
|
|
matches!(
|
|
envelope.item,
|
|
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
|
|
)
|
|
}) {
|
|
checkpoint
|
|
.metadata
|
|
.get_or_insert_default()
|
|
.compaction_model_hash = compaction_model_hash.clone();
|
|
}
|
|
// Wait for accepted updates to finish persisting, then keep later updates from
|
|
// overtaking the current settings snapshot while its checkpoint is written.
|
|
let _settings_guard = thread_settings::acquire_persistence_lock(self).await;
|
|
let (compacted_item, started_item) = {
|
|
let mut state = self.state.lock().await;
|
|
let complete_replacement =
|
|
matches!(&installation, CompactedHistoryInstallation::Complete(_));
|
|
let (metadata, started_item) = match installation {
|
|
CompactedHistoryInstallation::Complete(metadata) => (metadata, None),
|
|
CompactedHistoryInstallation::Prefix {
|
|
prefix,
|
|
prefix_context,
|
|
initial_context,
|
|
model_info,
|
|
compaction_response_id,
|
|
reviewer_compaction_hash,
|
|
turn_context,
|
|
started_item,
|
|
} => {
|
|
// Validate immediately before slicing and replacing the captured prefix.
|
|
if state.history.history_version() != prefix.generation
|
|
|| state.history.annotated_items().len() < prefix.len
|
|
|| self.services.model_client.auth_owner_generation()
|
|
!= prefix.auth_owner_generation
|
|
{
|
|
return false;
|
|
}
|
|
let current = state.history.annotated_items();
|
|
let suffix = ¤t[prefix.len..];
|
|
if suffix.iter().any(|envelope| {
|
|
crate::compact_retained_tool_outputs::is_retained_tool_output(envelope)
|
|
|| compact::is_real_user_or_agent_message(&envelope.item)
|
|
}) {
|
|
items.extend(initial_context);
|
|
} else {
|
|
items = compact::insert_initial_context_before_last_real_user_or_summary(
|
|
items,
|
|
initial_context,
|
|
);
|
|
}
|
|
items.splice(
|
|
0..0,
|
|
prefix_context.into_iter().map(ResponseItemEnvelope::new),
|
|
);
|
|
items.extend_from_slice(suffix);
|
|
// The unchanged suffix already contains updates through this baseline.
|
|
if let Some(checkpoint) = state.history.world_state_checkpoint() {
|
|
world_state_baseline = WorldStateSnapshot::from(&checkpoint.state);
|
|
}
|
|
// Prefix replacement already retains all edits in the live suffix.
|
|
let input_goal_ids = Default::default();
|
|
let (window_number, window_ids) = state.advance_auto_compact_window();
|
|
(
|
|
CompactedHistoryMetadata {
|
|
input_goal_ids,
|
|
message: String::new(),
|
|
window_number,
|
|
window_ids,
|
|
compaction_response_id: Some(compaction_response_id),
|
|
compaction_model_hash: model_info.comp_hash.clone(),
|
|
reviewer_compaction_hash,
|
|
},
|
|
started_item.map(|item| (turn_context, item)),
|
|
)
|
|
}
|
|
};
|
|
for envelope in &mut items {
|
|
Self::assign_missing_response_item_id(&mut envelope.item);
|
|
}
|
|
// Goal edits are published outside the running task. Keep edits accepted after
|
|
// the compaction input snapshot, in their original order, after its replacement.
|
|
if complete_replacement {
|
|
let replacement_goal_ids = crate::context::UserGoalUpdate::message_ids(
|
|
items.iter().map(|envelope| &envelope.item),
|
|
);
|
|
items.extend(
|
|
state
|
|
.history
|
|
.annotated_items()
|
|
.iter()
|
|
.filter(|envelope| {
|
|
crate::context::UserGoalUpdate::message_text(&envelope.item).is_some()
|
|
&& envelope.item.id().is_some_and(|id| {
|
|
!metadata.input_goal_ids.contains(id)
|
|
&& !replacement_goal_ids.contains(id)
|
|
})
|
|
})
|
|
.cloned(),
|
|
);
|
|
}
|
|
let replacement_history = items.clone();
|
|
state.replace_annotated_history(
|
|
items,
|
|
Some(reference_context_item.clone()),
|
|
HistoryReplacement::Compaction {
|
|
reviewer_compaction_hash: metadata.reviewer_compaction_hash,
|
|
},
|
|
);
|
|
state.reasoning_effort_pin = ReasoningEffortPin::Compacted;
|
|
state
|
|
.history
|
|
.set_world_state_baseline(world_state_baseline.clone());
|
|
let compacted_item = CompactedItem {
|
|
message: metadata.message,
|
|
replacement_history: Some(replacement_history),
|
|
guardian_history: state.history.guardian_history_checkpoint(),
|
|
retained_context: Some(state.history.retained_context().clone()),
|
|
mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(),
|
|
window_number: Some(metadata.window_number),
|
|
first_window_id: Some(metadata.window_ids.first_window_id.to_string()),
|
|
previous_window_id: metadata
|
|
.window_ids
|
|
.previous_window_id
|
|
.map(|id| id.to_string()),
|
|
window_id: Some(metadata.window_ids.window_id.to_string()),
|
|
compaction_response_id: metadata.compaction_response_id,
|
|
latest_token_usage_record: state.latest_token_usage_record.clone(),
|
|
resume_metadata: Some(CompactionResumeMetadata {
|
|
dynamic_tools: Some(Box::new(DynamicToolsSnapshot {
|
|
thread_id: self.thread_id(),
|
|
tools: state.persisted_dynamic_tools.to_vec(),
|
|
})),
|
|
multi_agent_version: self.multi_agent_version(),
|
|
last_started_turn_id: state.last_started_turn_id.clone(),
|
|
turn_attribution: state.turn_attribution.clone(),
|
|
previous_turn_settings: state.previous_turn_settings(),
|
|
}),
|
|
};
|
|
(compacted_item, started_item)
|
|
};
|
|
|
|
if let Some((turn_context, item)) = started_item {
|
|
// The prefix is now known to be valid; announce the install before checkpoint I/O.
|
|
self.emit_turn_item_started(turn_context, item).await;
|
|
}
|
|
let world_state_item = WorldStateItem::full(world_state_baseline.into_object());
|
|
let rollout_items = [
|
|
RolloutItem::Compacted(Box::new(compacted_item)),
|
|
RolloutItem::WorldState(world_state_item),
|
|
RolloutItem::TurnContext(reference_context_item),
|
|
// The frozen turn context must not override current settings in persisted metadata.
|
|
RolloutItem::EventMsg(thread_settings::applied_event(self).await),
|
|
];
|
|
if self.persist_rollout_items(&rollout_items).await
|
|
&& let Some(revision) = mcp_revision
|
|
{
|
|
self.services
|
|
.executed_tool_calls
|
|
.mark_mcp_attribution_persisted(revision);
|
|
}
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn preserve_parent_tool_declarations(&self) -> bool {
|
|
// Only prediction forks and thread/sample set this marker when they disable MCP.
|
|
self.enabled(Feature::PredictionPreserveParentTools)
|
|
&& self
|
|
.services
|
|
.thread_extension_data
|
|
.get::<crate::McpServersDisabled>()
|
|
.is_some()
|
|
}
|
|
|
|
pub fn enabled(&self, feature: Feature) -> bool {
|
|
self.features.enabled(feature)
|
|
}
|
|
|
|
pub(crate) fn features(&self) -> ManagedFeatures {
|
|
self.features.clone()
|
|
}
|
|
|
|
pub(crate) async fn collaboration_mode(&self) -> CollaborationMode {
|
|
let state = self.state.lock().await;
|
|
state
|
|
.session_configuration
|
|
.step_settings
|
|
.collaboration_mode
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) fn multi_agent_version(&self) -> Option<MultiAgentVersion> {
|
|
self.multi_agent_version.get().copied()
|
|
}
|
|
|
|
pub(crate) fn set_multi_agent_version_if_unset(
|
|
&self,
|
|
multi_agent_version: MultiAgentVersion,
|
|
) -> MultiAgentVersion {
|
|
*self.multi_agent_version.get_or_init(|| multi_agent_version)
|
|
}
|
|
|
|
pub(crate) fn resolve_multi_agent_version_for_model(
|
|
&self,
|
|
model_info: &ModelInfo,
|
|
config: &Config,
|
|
) -> MultiAgentVersion {
|
|
if let Some(multi_agent_version) = self.multi_agent_version() {
|
|
return config.multi_agent_version_for_model(Some(multi_agent_version));
|
|
}
|
|
|
|
let selected = config.multi_agent_version_for_model(model_info.multi_agent_version);
|
|
|
|
self.set_multi_agent_version_if_unset(selected)
|
|
}
|
|
|
|
#[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))]
|
|
async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) {
|
|
for item in items {
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::RawResponseItem(RawResponseItemEvent { item: item.clone() }),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn build_turn_context_contribution_items(
|
|
&self,
|
|
step_context: &StepContext,
|
|
) -> Vec<ResponseItem> {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let mut developer_sections = Vec::new();
|
|
let context_contributors = self.services.extensions.context_contributors().to_vec();
|
|
|
|
for contributor in &context_contributors {
|
|
for fragment in contributor
|
|
.contribute_turn_context(TurnContextContributionInput {
|
|
thread_id: self.thread_id(),
|
|
turn_id: turn_context.sub_id.as_str(),
|
|
session_store: &self.services.session_extension_data,
|
|
thread_store: &self.services.thread_extension_data,
|
|
turn_store: turn_context.extension_data.as_ref(),
|
|
model_context_window: step_context.settings.model_info.usable_context_window(),
|
|
})
|
|
.await
|
|
{
|
|
developer_sections.push(fragment.into());
|
|
}
|
|
}
|
|
|
|
crate::context_manager::updates::build_rendered_message(developer_sections)
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
|
|
/// Normally both inputs come from the same step. Async compaction uses the prefix's
|
|
/// world state so later state updates can stay in history unchanged.
|
|
/// Returns rendered items with placement preserved for the caller's history layout.
|
|
pub(crate) async fn build_initial_context_with_world_state(
|
|
&self,
|
|
step_context: &StepContext,
|
|
world_state: &WorldState,
|
|
) -> (Vec<WorldStateUpdate>, WorldStateSnapshot) {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let mut developer_sections = Vec::<RenderedFragment>::with_capacity(8);
|
|
let mut contextual_user_sections = Vec::<RenderedFragment>::with_capacity(2);
|
|
let mut separate_developer_messages = Vec::<ResponseItem>::new();
|
|
let mut context_window_hints = Vec::new();
|
|
let (session_source, auto_compact_window_ids) = {
|
|
let state = self.state.lock().await;
|
|
(
|
|
state.session_configuration.session_source.clone(),
|
|
state.auto_compact_window_ids(),
|
|
)
|
|
};
|
|
let separate_guardian_developer_message =
|
|
crate::guardian::is_basic_session_source(&session_source);
|
|
// Keep the guardian policy prompt out of the aggregated developer bundle so it
|
|
// stays isolated as its own top-level developer message for guardian subagents.
|
|
if !separate_guardian_developer_message
|
|
&& let Some(developer_instructions) = turn_context.developer_instructions.as_deref()
|
|
&& !developer_instructions.is_empty()
|
|
{
|
|
developer_sections
|
|
.push(DeveloperInstructions::new(developer_instructions).render_fragment());
|
|
}
|
|
let loaded_plugins = self
|
|
.services
|
|
.plugins_manager
|
|
.plugins_for_config(&turn_context.config.plugins_config_input())
|
|
.await
|
|
.without_plugins(&turn_context.disabled_plugin_ids);
|
|
let recommended_plugin_candidates = if turn_context
|
|
.config
|
|
.features
|
|
.plugin_recommendations_enabled()
|
|
{
|
|
let auth = self.services.auth_manager.auth().await;
|
|
let plugins_config = turn_context.config.plugins_config_input();
|
|
self.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
|
|
};
|
|
let context_contributors = self.services.extensions.context_contributors().to_vec();
|
|
for contributor in &context_contributors {
|
|
for fragment in contributor
|
|
.contribute_thread_context(
|
|
&self.services.session_extension_data,
|
|
&self.services.thread_extension_data,
|
|
)
|
|
.await
|
|
{
|
|
match fragment.slot() {
|
|
PromptSlot::ContextWindow => {
|
|
context_window_hints.push(fragment.into_content());
|
|
}
|
|
PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => {
|
|
developer_sections.push(fragment.into());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for contributor in &context_contributors {
|
|
for fragment in contributor
|
|
.contribute_turn_context(TurnContextContributionInput {
|
|
thread_id: self.thread_id(),
|
|
turn_id: turn_context.sub_id.as_str(),
|
|
session_store: &self.services.session_extension_data,
|
|
thread_store: &self.services.thread_extension_data,
|
|
turn_store: turn_context.extension_data.as_ref(),
|
|
model_context_window: step_context.settings.model_info.usable_context_window(),
|
|
})
|
|
.await
|
|
{
|
|
developer_sections.push(fragment.into());
|
|
}
|
|
}
|
|
// This is full-context metadata. Steady-state context diffs should not re-emit it.
|
|
if turn_context.config.features.enabled(Feature::TokenBudget)
|
|
&& step_context
|
|
.settings
|
|
.model_info
|
|
.resolved_context_window()
|
|
.is_some()
|
|
{
|
|
// Keep the legacy bridge hint when native Notes is disabled. A failed
|
|
// native request must not fall back to the bridge.
|
|
if !turn_context
|
|
.config
|
|
.token_budget
|
|
.as_ref()
|
|
.is_some_and(|config| config.use_history_notes_extension)
|
|
&& let Some((text, source_tool_namespace)) = self
|
|
.services
|
|
.mcp_runtime
|
|
.latest_call_tool(
|
|
"notes",
|
|
"thread_hint",
|
|
/*environment_id*/ None,
|
|
/*arguments*/ None,
|
|
Some(serde_json::json!({
|
|
"threadId": self.thread_id().to_string(),
|
|
})),
|
|
/*requested_timeout*/ None,
|
|
/*wait_for_server*/ true,
|
|
)
|
|
.await
|
|
.ok()
|
|
.and_then(|call| {
|
|
let text = call
|
|
.result
|
|
.content
|
|
.iter()
|
|
.filter_map(|content| {
|
|
content.get("text").and_then(serde_json::Value::as_str)
|
|
})
|
|
.filter(|text| !text.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
(!text.is_empty()).then_some((text, call.source_tool_namespace))
|
|
})
|
|
{
|
|
context_window_hints.push(codex_context_fragments::AnnotatedContent::text(
|
|
text,
|
|
ContentItemKind("notes.thread_hint".to_string()),
|
|
ContentItemMetadata::tool(source_tool_namespace.map(Into::into)),
|
|
));
|
|
}
|
|
separate_developer_messages.push(
|
|
crate::context::TokenBudgetContext::new(
|
|
session_source
|
|
.get_agent_path()
|
|
.unwrap_or_else(codex_protocol::AgentPath::root),
|
|
auto_compact_window_ids.first_window_id,
|
|
auto_compact_window_ids.previous_window_id,
|
|
auto_compact_window_ids.window_id,
|
|
)
|
|
.render_with_hints(context_window_hints),
|
|
);
|
|
}
|
|
let (snapshot, updates) = world_state.render_full();
|
|
let (mut prefix, context) = split_prefix_updates(updates);
|
|
let mut items = Vec::with_capacity(4);
|
|
let mut context = context.into_iter();
|
|
// Keep the initial-context grouping within each fragment run, but never across an item.
|
|
loop {
|
|
let mut next_item = None;
|
|
// Render the active mode after the usage hint so it can override that hint.
|
|
let mut initial_multi_agent_mode = None;
|
|
let mut managed_developer_instructions = None;
|
|
for update in context.by_ref() {
|
|
let fragment = match update.content {
|
|
WorldStateUpdateContent::Fragment(fragment) => fragment,
|
|
WorldStateUpdateContent::Item(item) => {
|
|
next_item = Some(*item);
|
|
break;
|
|
}
|
|
};
|
|
match fragment.role() {
|
|
"developer"
|
|
if fragment.markers().0 == ModelSwitchInstructions::type_markers().0 =>
|
|
{
|
|
// New-model instructions must precede the rest of the developer context.
|
|
developer_sections.insert(0, fragment.render_fragment());
|
|
}
|
|
"developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => {
|
|
initial_multi_agent_mode = Some(fragment);
|
|
}
|
|
"developer"
|
|
if fragment.markers().0
|
|
== ManagedDeveloperInstructions::type_markers().0 =>
|
|
{
|
|
managed_developer_instructions = Some(fragment);
|
|
}
|
|
"developer"
|
|
if fragment.markers().0 == MultiAgentRoleInstructions::type_markers().0 =>
|
|
{
|
|
separate_developer_messages.push(fragment.render_fragment().into());
|
|
}
|
|
"developer"
|
|
if update.placement == Placement::Standalone
|
|
&& fragment.markers().0.is_empty() =>
|
|
{
|
|
separate_developer_messages.push(fragment.render_fragment().into());
|
|
}
|
|
"developer" => developer_sections.push(fragment.render_fragment()),
|
|
"user" => contextual_user_sections.push(fragment.render_fragment()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if next_item.is_none()
|
|
&& let Some(recommended_plugins) = recommended_plugin_candidates
|
|
.as_deref()
|
|
.and_then(RecommendedPluginsInstructions::from_plugins)
|
|
{
|
|
developer_sections.push(recommended_plugins.render_fragment());
|
|
}
|
|
|
|
if let Some(developer_message) = crate::context_manager::updates::build_rendered_message(
|
|
std::mem::take(&mut developer_sections),
|
|
) {
|
|
items.push(developer_message);
|
|
}
|
|
items.append(&mut separate_developer_messages);
|
|
if let Some(initial_multi_agent_mode) = initial_multi_agent_mode
|
|
&& let Some(message) =
|
|
crate::context_manager::updates::build_rendered_message(vec![
|
|
initial_multi_agent_mode.render_fragment(),
|
|
])
|
|
{
|
|
items.push(message);
|
|
}
|
|
if let Some(contextual_user_message) =
|
|
crate::context_manager::updates::build_rendered_message(std::mem::take(
|
|
&mut contextual_user_sections,
|
|
))
|
|
{
|
|
items.push(contextual_user_message);
|
|
}
|
|
// Emit the guardian policy prompt as a separate developer item so the guardian
|
|
// subagent sees a distinct, easy-to-audit instruction block.
|
|
if next_item.is_none()
|
|
&& separate_guardian_developer_message
|
|
&& let Some(developer_instructions) = turn_context.developer_instructions.as_deref()
|
|
&& !developer_instructions.is_empty()
|
|
&& let Some(guardian_developer_message) =
|
|
crate::context_manager::updates::build_rendered_message(vec![
|
|
GuardianPolicy::new(developer_instructions).render_fragment(),
|
|
])
|
|
{
|
|
items.push(guardian_developer_message);
|
|
}
|
|
if let Some(managed_developer_instructions) = managed_developer_instructions
|
|
&& let Some(message) =
|
|
crate::context_manager::updates::build_rendered_message(vec![
|
|
managed_developer_instructions.render_fragment(),
|
|
])
|
|
{
|
|
items.push(message);
|
|
}
|
|
|
|
if let Some(item) = next_item {
|
|
items.push(item);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// New context windows and compaction install these items directly into replacement history.
|
|
for item in prefix.iter_mut().chain(&mut items) {
|
|
item.set_turn_id_if_missing(&turn_context.sub_id);
|
|
}
|
|
let updates = prefix
|
|
.into_iter()
|
|
.map(WorldStateUpdate::prefix_item)
|
|
.chain(items.into_iter().map(|item| WorldStateUpdate {
|
|
placement: Placement::Standalone,
|
|
content: WorldStateUpdateContent::Item(Box::new(item)),
|
|
}))
|
|
.collect();
|
|
(updates, snapshot)
|
|
}
|
|
|
|
#[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))]
|
|
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) -> bool {
|
|
if let Some(live_thread) = self.live_thread()
|
|
&& let Err(e) = live_thread.append_items(items).await
|
|
{
|
|
error!("failed to record rollout items: {e:#}");
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) async fn clone_history(&self) -> ContextManager {
|
|
let state = self.state.lock().await;
|
|
state.clone_history()
|
|
}
|
|
|
|
/// Captures the history lifetime without exposing mutable session state.
|
|
pub(crate) async fn history_reset(&self) -> (u64, CancellationToken) {
|
|
let state = self.state.lock().await;
|
|
(
|
|
state.history.reset_version,
|
|
state.history_reset.child_token(),
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn conversation_history_snapshot(
|
|
&self,
|
|
) -> Arc<dyn ConversationHistorySnapshot> {
|
|
let state = self.state.lock().await;
|
|
state.history.conversation_history_snapshot()
|
|
}
|
|
|
|
pub(crate) async fn current_window_id(&self) -> String {
|
|
self.current_window().await.0
|
|
}
|
|
|
|
pub(crate) async fn current_window(&self) -> (String, u64, Uuid) {
|
|
let state = self.state.lock().await;
|
|
self.current_window_from_state(&state)
|
|
}
|
|
|
|
pub(crate) fn current_window_from_state(&self, state: &SessionState) -> (String, u64, Uuid) {
|
|
let thread_id = self.thread_id;
|
|
let window_number = state.auto_compact_window_number();
|
|
let context_window_id = state.auto_compact_window_ids().window_id;
|
|
(
|
|
format!("{thread_id}:{window_number}"),
|
|
window_number,
|
|
context_window_id,
|
|
)
|
|
}
|
|
|
|
pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) {
|
|
let mut state = self.state.lock().await;
|
|
state.advance_auto_compact_window()
|
|
}
|
|
|
|
pub(crate) async fn request_new_context_window(&self) {
|
|
let mut state = self.state.lock().await;
|
|
state.request_new_context_window();
|
|
}
|
|
|
|
pub(crate) async fn take_new_context_window_request(&self) -> bool {
|
|
let mut state = self.state.lock().await;
|
|
state.take_new_context_window_request()
|
|
}
|
|
|
|
pub(crate) async fn start_new_context_window(
|
|
&self,
|
|
step_context: &StepContext,
|
|
world_state: Arc<WorldState>,
|
|
) {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let history = self.clone_history().await;
|
|
let input_goal_ids = crate::context::UserGoalUpdate::message_ids(history.raw_items());
|
|
let retained_client_items =
|
|
crate::compact_remote_v2::truncate_retained_messages_for_remote_compaction(
|
|
history
|
|
.annotated_items()
|
|
.iter()
|
|
.filter(|item| {
|
|
(self.enabled(Feature::RetainClientToolOutputs)
|
|
&& crate::compact_retained_tool_outputs::is_retained_tool_output(item))
|
|
|| (self.enabled(Feature::RetainClientDeveloperMessages)
|
|
&& crate::compact_remote_v2::is_client_authored_developer_message(
|
|
item,
|
|
))
|
|
})
|
|
.cloned()
|
|
.collect(),
|
|
crate::compact_remote_v2::RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
);
|
|
let window = {
|
|
let mut state = self.state.lock().await;
|
|
state.start_new_context_window()
|
|
};
|
|
let (window_number, window_ids) = window;
|
|
let (context_updates, world_state_snapshot) = self
|
|
.build_initial_context_with_world_state(step_context, world_state.as_ref())
|
|
.await;
|
|
let context_items =
|
|
crate::context_manager::updates::merge_world_state_updates(context_updates)
|
|
.into_iter()
|
|
.map(ResponseItemEnvelope::new)
|
|
.chain(retained_client_items)
|
|
.collect();
|
|
let turn_context_item = step_context.to_turn_context_item();
|
|
self.replace_compacted_history(
|
|
context_items,
|
|
turn_context_item,
|
|
world_state_snapshot,
|
|
CompactedHistoryMetadata {
|
|
input_goal_ids,
|
|
message: String::new(),
|
|
window_number,
|
|
window_ids,
|
|
compaction_response_id: None,
|
|
compaction_model_hash: None,
|
|
reviewer_compaction_hash: None,
|
|
},
|
|
)
|
|
.await;
|
|
self.recompute_token_usage(turn_context).await;
|
|
}
|
|
|
|
pub(crate) async fn reference_context_item(&self) -> Option<TurnContextItem> {
|
|
let state = self.state.lock().await;
|
|
state.reference_context_item()
|
|
}
|
|
|
|
/// Persist the latest turn context snapshot for the first real user turn and for
|
|
/// steady-state turns that emit model-visible context updates.
|
|
///
|
|
/// When the reference snapshot is missing, this injects full initial context. Otherwise, it
|
|
/// emits only context diffs.
|
|
///
|
|
/// If full context is injected and a model switch occurred, this prepends the
|
|
/// `<model_switch>` developer message so model-specific instructions are not lost.
|
|
///
|
|
/// This is the normal runtime path that establishes a new `reference_context_item`.
|
|
/// Compaction also establishes that reference when it installs full initial context
|
|
/// into replacement history. Live world-state changes may
|
|
/// independently advance their in-memory baseline within a turn.
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub(crate) async fn record_context_updates_and_set_reference_context_item(
|
|
&self,
|
|
step_context: &StepContext,
|
|
) -> CodexResult<Arc<WorldState>> {
|
|
let turn_context = step_context.turn.as_ref();
|
|
let reference_context_item = {
|
|
let state = self.state.lock().await;
|
|
state.reference_context_item()
|
|
};
|
|
let turn_context_item = step_context.to_turn_context_item();
|
|
let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item);
|
|
let should_inject_full_context = reference_context_item.is_none();
|
|
let world_state = self
|
|
.build_world_state_for_step(step_context, should_inject_full_context)
|
|
.await?;
|
|
// Full initial context resets the baseline; later turns persist only its changes.
|
|
let (mut context_items, world_state_item) = if should_inject_full_context {
|
|
let (context_updates, snapshot) = self
|
|
.build_initial_context_with_world_state(step_context, &world_state)
|
|
.await;
|
|
let context_items =
|
|
crate::context_manager::updates::merge_world_state_updates(context_updates);
|
|
self.state
|
|
.lock()
|
|
.await
|
|
.history
|
|
.set_world_state_baseline(snapshot.clone());
|
|
(
|
|
context_items,
|
|
Some(WorldStateItem::full(snapshot.into_object())),
|
|
)
|
|
} else {
|
|
let (world_state_items, world_state_item) = {
|
|
let mut state = self.state.lock().await;
|
|
let (fragments, rollout_item) = state.history.update_world_state(&world_state);
|
|
(
|
|
crate::context_manager::updates::merge_world_state_updates(fragments),
|
|
rollout_item,
|
|
)
|
|
};
|
|
(world_state_items, world_state_item)
|
|
};
|
|
if !should_inject_full_context && turn_context_changed {
|
|
context_items.extend(
|
|
self.build_turn_context_contribution_items(step_context)
|
|
.await,
|
|
);
|
|
}
|
|
// A snapshot can change without producing model-visible or TurnContext updates.
|
|
let only_world_state_changed = !turn_context_changed && context_items.is_empty();
|
|
if only_world_state_changed && world_state_item.is_none() {
|
|
return Ok(Arc::new(world_state));
|
|
}
|
|
if !context_items.is_empty() {
|
|
self.record_conversation_items(
|
|
turn_context,
|
|
&step_context.settings.model_info,
|
|
&context_items,
|
|
)
|
|
.await;
|
|
}
|
|
// Persist state only after any model-visible context generated from it.
|
|
if let Some(world_state_item) = world_state_item {
|
|
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
|
|
.await;
|
|
}
|
|
// A snapshot-only change does not require a duplicate TurnContext record.
|
|
if only_world_state_changed {
|
|
return Ok(Arc::new(world_state));
|
|
}
|
|
// Persist one `TurnContextItem` per real user turn so resume/lazy replay can recover the
|
|
// latest durable baseline even when this turn emitted no model-visible context diffs.
|
|
// Checkpoint current thread-owned settings after the first turn's context:
|
|
// the step may predate an acknowledged update or use executor-owned permissions.
|
|
// Serialize with settings updates so an older checkpoint cannot supersede a newer one.
|
|
let _settings_guard = if should_inject_full_context {
|
|
Some(thread_settings::acquire_persistence_lock(self).await)
|
|
} else {
|
|
None
|
|
};
|
|
let mut rollout_items = vec![RolloutItem::TurnContext(turn_context_item.clone())];
|
|
if should_inject_full_context {
|
|
rollout_items.push(RolloutItem::EventMsg(
|
|
thread_settings::applied_event(self).await,
|
|
));
|
|
}
|
|
self.persist_rollout_items(&rollout_items).await;
|
|
|
|
// Advance the persisted-settings baseline even when this turn emitted no model-visible
|
|
// context items.
|
|
let mut state = self.state.lock().await;
|
|
state.set_reference_context_item(Some(turn_context_item));
|
|
Ok(Arc::new(world_state))
|
|
}
|
|
|
|
/// Record reported spending without changing the context baseline or estimating usage.
|
|
pub(crate) async fn record_incomplete_response_usage(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
settings: &ResolvedStepSettings,
|
|
incomplete: &ResponseIncompleteError,
|
|
) {
|
|
if let Some(response_id) = &incomplete.response_id {
|
|
self.record_response_token_usage(
|
|
turn_context,
|
|
response_id,
|
|
incomplete.token_usage.as_ref(),
|
|
)
|
|
.await;
|
|
}
|
|
// Accounting may exhaust the budget; keep the original incomplete-response reason.
|
|
let _ = self
|
|
.record_token_usage_info(
|
|
turn_context,
|
|
settings,
|
|
incomplete.token_usage.as_ref(),
|
|
TokenUsageContext::SpendingOnly,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn update_token_usage_info(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
token_usage: Option<&TokenUsage>,
|
|
) -> CodexResult<()> {
|
|
let result = self
|
|
.record_token_usage_info(
|
|
turn_context,
|
|
&turn_context.initial_settings,
|
|
token_usage,
|
|
TokenUsageContext::Sampling,
|
|
)
|
|
.await;
|
|
self.send_token_count_event(turn_context).await;
|
|
result
|
|
}
|
|
|
|
pub(crate) async fn record_observed_response_completed(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
response_id: &str,
|
|
usage: Option<&TokenUsage>,
|
|
usage_metadata: Option<&ResponseUsageMetadata>,
|
|
) {
|
|
self.send_event(
|
|
turn_context,
|
|
EventMsg::RawResponseCompleted(RawResponseCompletedEvent {
|
|
response_id: response_id.to_string(),
|
|
token_usage: usage.cloned(),
|
|
usage_metadata: usage_metadata.cloned(),
|
|
}),
|
|
)
|
|
.await;
|
|
self.record_response_token_usage(turn_context, response_id, usage)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn record_response_token_usage(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
response_id: &str,
|
|
usage: Option<&TokenUsage>,
|
|
) {
|
|
let Some(usage) = usage else {
|
|
return;
|
|
};
|
|
let record = self.state.lock().await.record_token_usage(
|
|
self.thread_id,
|
|
&turn_context.sub_id,
|
|
self.session_id(),
|
|
turn_context
|
|
.turn_metadata_state
|
|
.root_turn_id()
|
|
.unwrap_or_else(|| turn_context.sub_id.clone()),
|
|
response_id.to_string(),
|
|
usage,
|
|
);
|
|
self.persist_rollout_items(&[RolloutItem::TokenUsageRecord(record)])
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn record_token_usage_info(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
settings: &ResolvedStepSettings,
|
|
token_usage: Option<&TokenUsage>,
|
|
context: TokenUsageContext,
|
|
) -> CodexResult<()> {
|
|
if let Some(token_usage) = token_usage {
|
|
let token_info = {
|
|
let mut state = self.state.lock().await;
|
|
match context {
|
|
TokenUsageContext::Sampling => {
|
|
state.update_token_info_from_usage(
|
|
token_usage,
|
|
turn_context.model_context_window(),
|
|
);
|
|
if matches!(
|
|
turn_context.config.model_auto_compact_token_limit_scope,
|
|
AutoCompactTokenLimitScope::BodyAfterPrefix
|
|
) {
|
|
state.ensure_auto_compact_window_server_prefill_from_usage(token_usage);
|
|
}
|
|
state.token_info()
|
|
}
|
|
TokenUsageContext::SpendingOnly => {
|
|
let mut info = state.token_info().unwrap_or(TokenUsageInfo {
|
|
total_token_usage: TokenUsage::default(),
|
|
last_token_usage: TokenUsage::default(),
|
|
model_context_window: turn_context.model_context_window(),
|
|
});
|
|
info.total_token_usage.add_assign(token_usage);
|
|
state.set_token_info(Some(info.clone()));
|
|
|
|
// Contributors receive the reported usage; stored context stays unchanged.
|
|
info.last_token_usage = token_usage.clone();
|
|
info.model_context_window = turn_context
|
|
.model_context_window()
|
|
.or(info.model_context_window);
|
|
Some(info)
|
|
}
|
|
}
|
|
};
|
|
let turn_state = self
|
|
.input_queue
|
|
.turn_state_for_sub_id(&self.active_turn, &turn_context.sub_id)
|
|
.await;
|
|
if let Some(turn_state) = turn_state {
|
|
turn_state.lock().await.token_usage_by_model.record(
|
|
settings.selected_collaboration_mode().model(),
|
|
settings.telemetry(&turn_context.session_telemetry),
|
|
token_usage,
|
|
);
|
|
}
|
|
let budget_result = self.record_rollout_budget_usage(token_usage).await;
|
|
if let Some(token_info) = token_info.as_ref() {
|
|
for contributor in self.services.extensions.token_usage_contributors() {
|
|
contributor
|
|
.on_token_usage(
|
|
&self.services.session_extension_data,
|
|
&self.services.thread_extension_data,
|
|
turn_context.extension_data.as_ref(),
|
|
token_info,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
budget_result?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
|
|
let history = self.clone_history().await;
|
|
let base_instructions = self.get_base_instructions().await;
|
|
let Some(estimated_total_tokens) =
|
|
history.estimate_token_count_with_base_instructions(&base_instructions)
|
|
else {
|
|
return;
|
|
};
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
let mut info = state.token_info().unwrap_or(TokenUsageInfo {
|
|
total_token_usage: TokenUsage::default(),
|
|
last_token_usage: TokenUsage::default(),
|
|
model_context_window: None,
|
|
});
|
|
|
|
info.last_token_usage = TokenUsage {
|
|
input_tokens: 0,
|
|
cached_input_tokens: 0,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 0,
|
|
reasoning_output_tokens: 0,
|
|
total_tokens: estimated_total_tokens.max(0),
|
|
codex_rollout_budget_units: None,
|
|
};
|
|
|
|
if let Some(model_context_window) = turn_context.model_context_window() {
|
|
info.model_context_window = Some(model_context_window);
|
|
}
|
|
|
|
state.set_token_info(Some(info));
|
|
// The local estimate already includes every retained reasoning item.
|
|
state.set_server_reasoning_included(/*included*/ true);
|
|
}
|
|
self.set_auto_compact_window_estimated_prefill_for_scope(
|
|
turn_context,
|
|
estimated_total_tokens,
|
|
)
|
|
.await;
|
|
self.send_token_count_event(turn_context).await;
|
|
}
|
|
|
|
pub(crate) async fn update_rate_limits(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
new_rate_limits: RateLimitSnapshot,
|
|
) {
|
|
self.record_rate_limits_info(new_rate_limits).await;
|
|
self.send_token_count_event(turn_context).await;
|
|
}
|
|
|
|
pub(crate) async fn record_rate_limits_info(&self, new_rate_limits: RateLimitSnapshot) {
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.set_rate_limits(new_rate_limits);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn mcp_dependency_prompted(&self) -> HashSet<String> {
|
|
let state = self.state.lock().await;
|
|
state.mcp_dependency_prompted()
|
|
}
|
|
|
|
pub(crate) async fn record_mcp_dependency_prompted<I>(&self, names: I)
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let mut state = self.state.lock().await;
|
|
state.record_mcp_dependency_prompted(names);
|
|
}
|
|
|
|
pub(crate) async fn set_server_reasoning_included(&self, included: bool) {
|
|
let mut state = self.state.lock().await;
|
|
state.set_server_reasoning_included(included);
|
|
}
|
|
|
|
pub(crate) async fn send_token_count_event(&self, turn_context: &TurnContext) {
|
|
let (info, rate_limits) = {
|
|
let state = self.state.lock().await;
|
|
state.token_info_and_rate_limits()
|
|
};
|
|
let event = EventMsg::TokenCount(TokenCountEvent { info, rate_limits });
|
|
self.send_event(turn_context, event).await;
|
|
}
|
|
|
|
pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
|
|
if let Some(context_window) = turn_context.model_context_window() {
|
|
let mut state = self.state.lock().await;
|
|
state.set_token_usage_full(context_window);
|
|
}
|
|
self.send_token_count_event(turn_context).await;
|
|
}
|
|
|
|
pub(crate) async fn record_response_item_and_emit_turn_item(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
response_item: ResponseItem,
|
|
) {
|
|
// Add to conversation history and persist response item to rollout.
|
|
self.record_conversation_items(
|
|
turn_context,
|
|
model_info,
|
|
std::slice::from_ref(&response_item),
|
|
)
|
|
.await;
|
|
|
|
// Derive a turn item and emit lifecycle events if applicable.
|
|
if let Some(item) = parse_turn_item(&response_item) {
|
|
self.emit_turn_item_started(turn_context, &item).await;
|
|
self.emit_turn_item_completed(turn_context, item).await;
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn record_user_prompt_and_emit_turn_item(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
model_info: &ModelInfo,
|
|
input: &[UserInput],
|
|
client_id: Option<String>,
|
|
metadata: UserInputMetadata,
|
|
persist_context: PersistContext,
|
|
) {
|
|
// Persist the user message to history, but emit the turn item from `UserInput` so
|
|
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
|
|
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
|
|
let mut user_image_content_indices = HashMap::new();
|
|
let mut response_item = self.response_item_from_user_input_with_image_positions(
|
|
input.to_vec(),
|
|
&mut user_image_content_indices,
|
|
);
|
|
if let Some(uuid) = metadata.response_item_uuid {
|
|
response_item.set_id(Some(ResponseItemId::for_admitted_user_input(uuid)));
|
|
}
|
|
if metadata.origin == codex_history::UserInputOrigin::Heartbeat
|
|
&& let ResponseItem::Message {
|
|
content,
|
|
internal_chat_message_metadata_passthrough: Some(metadata),
|
|
..
|
|
} = &mut response_item
|
|
&& matches!(content.as_slice(), [ContentItem::InputText { .. }])
|
|
{
|
|
metadata.content_item_kinds = Some(vec![ContentItemKind(
|
|
codex_history::HEARTBEAT_CONTENT_KIND.to_owned(),
|
|
)]);
|
|
}
|
|
let (prepared_items, image_preparations) = self
|
|
.prepare_annotated_conversation_items_for_history(
|
|
turn_context,
|
|
model_info,
|
|
vec![ResponseItemEnvelope {
|
|
item: response_item,
|
|
metadata: metadata.acceptance_order.map(|order| CodexHarnessMetadata {
|
|
user_input_order: Some(order),
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
)
|
|
.await;
|
|
let mut user_message_item = UserMessageItem::new(input);
|
|
apply_prepared_image_file_ids(
|
|
&mut user_message_item,
|
|
&prepared_items,
|
|
&user_image_content_indices,
|
|
);
|
|
self.record_prepared_conversation_items(
|
|
turn_context,
|
|
model_info,
|
|
prepared_items,
|
|
image_preparations,
|
|
)
|
|
.await;
|
|
user_message_item.client_id = client_id;
|
|
let turn_item = TurnItem::UserMessage(user_message_item);
|
|
self.emit_turn_item_started(turn_context, &turn_item).await;
|
|
self.emit_turn_item_completed(turn_context, turn_item).await;
|
|
self.ensure_rollout_materialized(persist_context).await;
|
|
}
|
|
|
|
pub(crate) async fn notify_stream_error(
|
|
&self,
|
|
turn_context: &TurnContext,
|
|
message: impl Into<String>,
|
|
codex_error: CodexErr,
|
|
) {
|
|
let additional_details = codex_error.to_string();
|
|
let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected {
|
|
http_status_code: codex_error.http_status_code_value(),
|
|
};
|
|
let event = EventMsg::StreamError(StreamErrorEvent {
|
|
message: message.into(),
|
|
codex_error_info: Some(codex_error_info),
|
|
additional_details: Some(additional_details),
|
|
});
|
|
self.send_event(turn_context, event).await;
|
|
}
|
|
|
|
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
|
|
let turn_state = self
|
|
.input_queue
|
|
.turn_state_for_sub_id(&self.active_turn, sub_id)
|
|
.await;
|
|
let Some(turn_state) = turn_state else {
|
|
return;
|
|
};
|
|
turn_state.lock().await.has_memory_citation = true;
|
|
}
|
|
|
|
pub async fn interrupt_task(self: &Arc<Self>) {
|
|
info!("interrupt received: abort current task, if any");
|
|
let had_active_turn = self.active_turn.lock().await.is_some();
|
|
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
if !had_active_turn {
|
|
self.cancel_mcp_startup();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hooks(&self) -> Arc<Hooks> {
|
|
self.services.hooks.load_full()
|
|
}
|
|
|
|
pub(crate) fn user_shell(&self) -> Arc<shell::Shell> {
|
|
Arc::clone(&self.services.user_shell)
|
|
}
|
|
|
|
pub(crate) async fn current_rollout_path(&self) -> anyhow::Result<Option<PathBuf>> {
|
|
let Some(live_thread) = self.live_thread() else {
|
|
return Ok(None);
|
|
};
|
|
live_thread.local_rollout_path().await.map_err(Into::into)
|
|
}
|
|
|
|
pub(crate) async fn hook_transcript_path(&self) -> Option<PathBuf> {
|
|
let rollout_path = match self.current_rollout_path().await {
|
|
Ok(Some(path)) => path,
|
|
Ok(None) => return None,
|
|
Err(err) => {
|
|
warn!("{err}");
|
|
return None;
|
|
}
|
|
};
|
|
self.ensure_rollout_materialized(PersistContext::Standard)
|
|
.await;
|
|
Some(rollout_path)
|
|
}
|
|
|
|
pub(crate) async fn take_pending_session_start_source(
|
|
&self,
|
|
) -> Option<codex_hooks::SessionStartSource> {
|
|
let mut state = self.state.lock().await;
|
|
state.take_pending_session_start_source()
|
|
}
|
|
|
|
fn show_raw_agent_reasoning(&self) -> bool {
|
|
self.services.show_raw_agent_reasoning
|
|
}
|
|
}
|
|
|
|
fn apply_prepared_image_file_ids(
|
|
user_message_item: &mut UserMessageItem,
|
|
prepared_items: &[ResponseItemEnvelope],
|
|
user_image_content_indices: &HashMap<usize, usize>,
|
|
) {
|
|
// Preparation replaces image slots in place; resize notices are separate messages.
|
|
// Read the references from the original message while retaining UI-only input spans.
|
|
if let Some(ResponseItemEnvelope {
|
|
item: ResponseItem::Message { content, .. },
|
|
..
|
|
}) = prepared_items.first()
|
|
{
|
|
for (&input_index, &content_index) in user_image_content_indices {
|
|
if let Some(ContentItem::InputImage {
|
|
image: ImageReference::File { file_id },
|
|
..
|
|
}) = content.get(content_index)
|
|
&& let UserInput::Image { detail, .. } | UserInput::LocalImage { detail, .. } =
|
|
&user_message_item.content[input_index]
|
|
{
|
|
user_message_item.content[input_index] = UserInput::Image {
|
|
image: ImageReference::File {
|
|
file_id: file_id.clone(),
|
|
},
|
|
detail: *detail,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn emit_subagent_session_started(
|
|
analytics_events_client: &AnalyticsEventsClient,
|
|
client_metadata: AppServerClientMetadata,
|
|
session_id: SessionId,
|
|
thread_id: ThreadId,
|
|
parent_thread_id: Option<ThreadId>,
|
|
thread_config: ThreadConfigSnapshot,
|
|
subagent_source: SubAgentSource,
|
|
resumed_created_at: Option<DateTime<Utc>>,
|
|
) {
|
|
let initialization_mode = if resumed_created_at.is_some() {
|
|
ThreadInitializationMode::Resumed
|
|
} else {
|
|
ThreadInitializationMode::New
|
|
};
|
|
let created_at =
|
|
u64::try_from(resumed_created_at.unwrap_or_else(Utc::now).timestamp()).unwrap_or_default();
|
|
let AppServerClientMetadata {
|
|
client_name,
|
|
client_version,
|
|
} = client_metadata;
|
|
if (client_name.is_none() || client_version.is_none())
|
|
&& subagent_source.kind() != crate::guardian::GUARDIAN_REVIEWER_NAME
|
|
{
|
|
tracing::warn!("skipping subagent thread analytics: missing inherited client metadata");
|
|
return;
|
|
}
|
|
let input = SubAgentThreadStartedInput {
|
|
session_id: session_id.to_string(),
|
|
thread_id: thread_id.to_string(),
|
|
parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()),
|
|
forked_from_thread_id: thread_config
|
|
.forked_from_thread_id
|
|
.map(|thread_id| thread_id.to_string()),
|
|
product_client_id: thread_config.originator.clone(),
|
|
client_name,
|
|
client_version,
|
|
model: thread_config.model,
|
|
ephemeral: thread_config.ephemeral,
|
|
thread_source: thread_config.thread_source,
|
|
subagent_source,
|
|
initialization_mode,
|
|
created_at,
|
|
};
|
|
analytics_events_client.track_subagent_thread_started(input);
|
|
}
|
|
|
|
/// Builds hook configuration for one config snapshot, including any enabled plugin hooks.
|
|
async fn build_hooks_config(
|
|
config: &Config,
|
|
plugins_manager: &PluginsManager,
|
|
environment: Option<&TurnEnvironment>,
|
|
disabled_plugin_ids: &[String],
|
|
) -> HooksConfig {
|
|
let (hook_shell_program, hook_shell_argv) = environment
|
|
.and_then(|environment| environment.shell.as_ref())
|
|
.map(|shell| {
|
|
let mut argv = shell.derive_exec_args("", /*use_login_shell*/ false);
|
|
let program = argv.remove(0);
|
|
let _ = argv.pop();
|
|
(Some(program), argv)
|
|
})
|
|
.unwrap_or_default();
|
|
let plugins_input = config.plugins_config_input();
|
|
let plugin_outcome = plugins_manager
|
|
.plugins_for_config(&plugins_input)
|
|
.await
|
|
.without_plugins(disabled_plugin_ids);
|
|
let plugin_hook_sources = plugin_outcome.effective_plugin_hook_sources();
|
|
let plugin_hook_load_warnings = plugin_outcome.effective_plugin_hook_warnings();
|
|
HooksConfig {
|
|
legacy_notify_argv: config.notify.clone(),
|
|
feature_enabled: config.features.enabled(Feature::CodexHooks),
|
|
bypass_hook_trust: config.bypass_hook_trust,
|
|
config_layer_stack: Some(config.config_layer_stack.clone()),
|
|
plugin_hook_sources,
|
|
plugin_hook_load_warnings,
|
|
shell_program: hook_shell_program,
|
|
shell_args: hook_shell_argv,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "elicitation_holders_tests.rs"]
|
|
mod elicitation_holders_tests;
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "world_state_item_tests.rs"]
|
|
mod world_state_item_tests;
|