mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Merge remote-tracking branch 'origin/main' into jif/remove-list-agents-last-task-message
# Conflicts: # codex-rs/core/src/agent/control.rs # codex-rs/core/src/agent/control_tests.rs
This commit is contained in:
@@ -49,6 +49,7 @@ codex-shell-command = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
@@ -133,6 +134,7 @@ openssl-sys = { workspace = true, features = ["vendored"] }
|
||||
codex-shell-escalation = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-extension-items = { workspace = true }
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-image-generation-extension = { workspace = true }
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"enum": [
|
||||
"auto",
|
||||
"prompt",
|
||||
"writes",
|
||||
"approve"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -469,6 +470,9 @@
|
||||
"computer_use": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"concurrent_reasoning_summaries": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connectors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1750,6 +1754,9 @@
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"multi_agent_mode_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"non_code_mode_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4835,6 +4842,9 @@
|
||||
"computer_use": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"concurrent_reasoning_summaries": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connectors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::config::Config;
|
||||
use crate::config::RolloutBudgetConfig;
|
||||
@@ -136,12 +138,12 @@ impl AgentControl {
|
||||
pub(crate) async fn send_input(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
initial_operation: Op,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.ensure_execution_capacity_for_op(agent_id, &initial_operation)
|
||||
self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true)
|
||||
.await?;
|
||||
self.send_input_after_capacity_check(agent_id, &state, initial_operation)
|
||||
self.send_input_after_capacity_check(agent_id, &state, input)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -149,12 +151,12 @@ impl AgentControl {
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
initial_operation: Op,
|
||||
input: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
state,
|
||||
state.send_op(agent_id, initial_operation).await,
|
||||
state.send_op(agent_id, input.into()).await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -163,14 +165,62 @@ impl AgentControl {
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
communication: InterAgentCommunication,
|
||||
agent_communication_context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
let op = Op::InterAgentCommunication { communication };
|
||||
self.ensure_execution_capacity_for_op(agent_id, &op).await?;
|
||||
self.handle_thread_request_result(agent_id, &state, state.send_op(agent_id, op).await)
|
||||
self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn)
|
||||
.await?;
|
||||
self.send_inter_agent_communication_after_capacity_check(
|
||||
agent_id,
|
||||
&state,
|
||||
communication,
|
||||
agent_communication_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_inter_agent_communication_after_capacity_check(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
self.submit_inter_agent_communication(agent_id, state, communication, context)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn submit_inter_agent_communication(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
) -> CodexResult<String> {
|
||||
let communication_for_log =
|
||||
crate::agent_communication::logging_enabled().then(|| communication.clone());
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if let (Some(communication), Ok(communication_id)) =
|
||||
(communication_for_log, result.as_ref())
|
||||
{
|
||||
crate::agent_communication::emit_agent_communication_send(
|
||||
communication_id,
|
||||
&context,
|
||||
&communication,
|
||||
agent_id,
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
pub(crate) async fn interrupt_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
@@ -447,8 +497,10 @@ impl AgentControl {
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let context =
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id);
|
||||
let _ = control
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.send_inter_agent_communication(parent_thread_id, communication, context)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -672,27 +724,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_input_preview(initial_operation: &Op) -> String {
|
||||
match initial_operation {
|
||||
Op::UserInput { items, .. } => items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path, .. } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Op::InterAgentCommunication { communication } => communication.content.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
pub(crate) fn render_input_preview(input: &[UserInput]) -> String {
|
||||
input
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path, .. } => {
|
||||
format!("[local_image:{}]", path.display())
|
||||
}
|
||||
UserInput::Skill { name, path, .. } => {
|
||||
format!("[skill:${name}]({})", path.display())
|
||||
}
|
||||
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
|
||||
|
||||
@@ -32,7 +32,16 @@ impl AgentControl {
|
||||
thread_id: ThreadId,
|
||||
op: &Op,
|
||||
) -> CodexResult<()> {
|
||||
if !op_starts_turn(op) {
|
||||
self.ensure_execution_capacity_for_turn_start(thread_id, op_starts_turn(op))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_execution_capacity_for_turn_start(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
starts_turn: bool,
|
||||
) -> CodexResult<()> {
|
||||
if !starts_turn {
|
||||
return Ok(());
|
||||
}
|
||||
let state = self.upgrade()?;
|
||||
|
||||
@@ -9,6 +9,17 @@ struct SpawnAgentThreadInheritance {
|
||||
exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
|
||||
}
|
||||
|
||||
/// Initial input delivered after a spawned agent acquires execution capacity.
|
||||
///
|
||||
/// V2 communication spawns keep the communication and its context paired so centralized
|
||||
/// submission and lifecycle logging cannot receive one without the other. Other spawn sources
|
||||
/// provide user input directly, making an uncontextualized inter-agent communication
|
||||
/// unrepresentable.
|
||||
enum SpawnInitialInput {
|
||||
UserInput(Vec<UserInput>),
|
||||
InterAgentCommunication(InterAgentCommunication, AgentCommunicationContext),
|
||||
}
|
||||
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
@@ -89,12 +100,12 @@ impl AgentControl {
|
||||
pub(crate) async fn spawn_agent(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
) -> CodexResult<ThreadId> {
|
||||
let spawned_agent = Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
initial_operation,
|
||||
SpawnInitialInput::UserInput(initial_input),
|
||||
session_source,
|
||||
SpawnAgentOptions::default(),
|
||||
))
|
||||
@@ -106,12 +117,34 @@ impl AgentControl {
|
||||
pub(crate) async fn spawn_agent_with_metadata(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
|
||||
) -> CodexResult<LiveAgent> {
|
||||
Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options))
|
||||
.await
|
||||
Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
SpawnInitialInput::UserInput(initial_input),
|
||||
session_source,
|
||||
options,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_agent_with_communication(
|
||||
&self,
|
||||
config: Config,
|
||||
communication: InterAgentCommunication,
|
||||
context: AgentCommunicationContext,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<LiveAgent> {
|
||||
Box::pin(self.spawn_agent_internal(
|
||||
config,
|
||||
SpawnInitialInput::InterAgentCommunication(communication, context),
|
||||
session_source,
|
||||
options,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_v2_agent_loaded(
|
||||
@@ -197,7 +230,7 @@ impl AgentControl {
|
||||
async fn spawn_agent_internal(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_operation: Op,
|
||||
initial_input: SpawnInitialInput,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<LiveAgent> {
|
||||
@@ -356,8 +389,21 @@ impl AgentControl {
|
||||
)
|
||||
.await;
|
||||
|
||||
self.send_input_after_capacity_check(new_thread.thread_id, &state, initial_operation)
|
||||
.await?;
|
||||
match initial_input {
|
||||
SpawnInitialInput::UserInput(input) => {
|
||||
self.send_input_after_capacity_check(new_thread.thread_id, &state, input)
|
||||
.await?;
|
||||
}
|
||||
SpawnInitialInput::InterAgentCommunication(communication, context) => {
|
||||
self.send_inter_agent_communication_after_capacity_check(
|
||||
new_thread.thread_id,
|
||||
&state,
|
||||
communication,
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if multi_agent_version != MultiAgentVersion::V2 {
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
|
||||
@@ -3,6 +3,8 @@ use crate::CodexThread;
|
||||
use crate::StateDbHandle;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
@@ -65,12 +67,11 @@ async fn test_config() -> (TempDir, Config) {
|
||||
test_config_with_cli_overrides(Vec::new()).await
|
||||
}
|
||||
|
||||
fn text_input(text: &str) -> Op {
|
||||
fn text_input(text: &str) -> Vec<UserInput> {
|
||||
vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
|
||||
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
|
||||
@@ -311,8 +312,7 @@ async fn send_input_errors_when_manager_dropped() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect_err("send_input should fail without a manager");
|
||||
@@ -423,8 +423,7 @@ async fn send_input_errors_when_thread_missing() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect_err("send_input should fail for missing thread");
|
||||
@@ -490,8 +489,7 @@ async fn send_input_submits_user_message() {
|
||||
vec![UserInput::Text {
|
||||
text: "hello from tests".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("send_input should succeed");
|
||||
@@ -531,7 +529,11 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.send_inter_agent_communication(thread_id, communication.clone())
|
||||
.send_inter_agent_communication(
|
||||
thread_id,
|
||||
communication.clone(),
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
|
||||
)
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
@@ -656,7 +658,11 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
|
||||
);
|
||||
harness
|
||||
.control
|
||||
.send_inter_agent_communication(spawned_agent.thread_id, communication.clone())
|
||||
.send_inter_agent_communication(
|
||||
spawned_agent.thread_id,
|
||||
communication.clone(),
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
|
||||
)
|
||||
.await
|
||||
.expect("send_inter_agent_communication should succeed after reload");
|
||||
let expected = (
|
||||
@@ -1408,6 +1414,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li
|
||||
config: harness.config.clone(),
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -2197,6 +2204,7 @@ async fn spawn_thread_subagents_persist_parent_originator_across_new_and_truncat
|
||||
config: harness.config.clone(),
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
const AGENT_COMMUNICATION_TARGET: &str = "codex_otel.agent_communication";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AgentCommunicationKind {
|
||||
Spawn,
|
||||
Message,
|
||||
Followup,
|
||||
Result,
|
||||
}
|
||||
|
||||
impl AgentCommunicationKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Spawn => "spawn",
|
||||
Self::Message => "message",
|
||||
Self::Followup => "followup",
|
||||
Self::Result => "result",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AgentCommunicationContext {
|
||||
kind: AgentCommunicationKind,
|
||||
sender_thread_id: ThreadId,
|
||||
}
|
||||
|
||||
impl AgentCommunicationContext {
|
||||
pub(crate) fn new(kind: AgentCommunicationKind, sender_thread_id: ThreadId) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
sender_thread_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn logging_enabled() -> bool {
|
||||
tracing::enabled!(target: AGENT_COMMUNICATION_TARGET, tracing::Level::INFO)
|
||||
}
|
||||
|
||||
pub(crate) fn emit_agent_communication_send(
|
||||
communication_id: &str,
|
||||
context: &AgentCommunicationContext,
|
||||
communication: &InterAgentCommunication,
|
||||
receiver_thread_id: ThreadId,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: AGENT_COMMUNICATION_TARGET,
|
||||
{
|
||||
event.name = "codex.agent_communication",
|
||||
communication_id,
|
||||
kind = context.kind.as_str(),
|
||||
state = "send",
|
||||
sender_thread_id = %context.sender_thread_id,
|
||||
receiver_thread_id = %receiver_thread_id,
|
||||
content = if communication.content.is_empty() {
|
||||
communication.encrypted_content.as_deref().unwrap_or_default()
|
||||
} else {
|
||||
communication.content.as_str()
|
||||
},
|
||||
},
|
||||
"agent communication"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_agent_communication_receive(communication_id: &str) {
|
||||
tracing::info!(
|
||||
target: AGENT_COMMUNICATION_TARGET,
|
||||
{
|
||||
event.name = "codex.agent_communication",
|
||||
communication_id,
|
||||
state = "receive",
|
||||
},
|
||||
"agent communication"
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,10 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
|
||||
AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render())
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.then(|| AppsInstructions.render())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+79
-25
@@ -56,16 +56,20 @@ use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
|
||||
use codex_api::ResponsesWsRequest;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_api::SseTelemetry;
|
||||
use codex_api::StreamOptions;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::WebsocketTelemetry;
|
||||
use codex_api::auth_header_telemetry;
|
||||
use codex_api::build_session_headers;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_api::response_create_client_metadata;
|
||||
use codex_http_client::ClientRouteClass;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_login::default_client::build_default_reqwest_client_for_route;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
@@ -169,7 +173,7 @@ pub(crate) struct CompactConversationRequestSettings {
|
||||
|
||||
fn reasoning_effort_for_request(effort: ReasoningEffortConfig) -> ReasoningEffortConfig {
|
||||
match effort {
|
||||
ReasoningEffortConfig::Ultra => ReasoningEffortConfig::Custom("max".to_string()),
|
||||
ReasoningEffortConfig::Ultra => ReasoningEffortConfig::Max,
|
||||
effort => effort,
|
||||
}
|
||||
}
|
||||
@@ -203,6 +207,7 @@ struct ModelClientState {
|
||||
include_timing_metrics: bool,
|
||||
beta_features_header: Option<String>,
|
||||
item_ids_enabled: bool,
|
||||
concurrent_reasoning_summaries_enabled: bool,
|
||||
include_attestation: bool,
|
||||
attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
disable_websockets: AtomicBool,
|
||||
@@ -248,6 +253,7 @@ pub struct ModelClient {
|
||||
state: Arc<ModelClientState>,
|
||||
agent_identity_policy: AgentIdentityAuthPolicy,
|
||||
prompt_cache_key_override: Option<String>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
}
|
||||
|
||||
/// A turn-scoped streaming session created from a [`ModelClient`].
|
||||
@@ -311,6 +317,7 @@ fn responses_request_properties_match(
|
||||
reasoning: previous_reasoning,
|
||||
store: previous_store,
|
||||
stream: previous_stream,
|
||||
stream_options: _,
|
||||
include: previous_include,
|
||||
service_tier: previous_service_tier,
|
||||
prompt_cache_key: previous_prompt_cache_key,
|
||||
@@ -327,6 +334,7 @@ fn responses_request_properties_match(
|
||||
reasoning: current_reasoning,
|
||||
store: current_store,
|
||||
stream: current_stream,
|
||||
stream_options: _,
|
||||
include: current_include,
|
||||
service_tier: current_service_tier,
|
||||
prompt_cache_key: current_prompt_cache_key,
|
||||
@@ -342,6 +350,8 @@ fn responses_request_properties_match(
|
||||
&& previous_reasoning == current_reasoning
|
||||
&& previous_store == current_store
|
||||
&& previous_stream == current_stream
|
||||
// Stream options control delivery for this response, not the context
|
||||
// referenced by `previous_response_id`.
|
||||
&& previous_include == current_include
|
||||
&& previous_service_tier == current_service_tier
|
||||
&& previous_prompt_cache_key == current_prompt_cache_key
|
||||
@@ -396,7 +406,9 @@ impl ModelClient {
|
||||
/// Creates a new session-scoped `ModelClient`.
|
||||
///
|
||||
/// All arguments are expected to be stable for the lifetime of a Codex session. Per-turn values
|
||||
/// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly.
|
||||
/// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. The
|
||||
/// HTTP client factory must come from the effective session configuration so every transport
|
||||
/// observes the resolved outbound proxy policy.
|
||||
pub fn new(
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
agent_identity_policy: AgentIdentityAuthPolicy,
|
||||
@@ -409,7 +421,9 @@ impl ModelClient {
|
||||
include_timing_metrics: bool,
|
||||
beta_features_header: Option<String>,
|
||||
item_ids_enabled: bool,
|
||||
concurrent_reasoning_summaries_enabled: bool,
|
||||
attestation_provider: Option<Arc<dyn AttestationProvider>>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Self {
|
||||
let model_provider = create_model_provider(provider_info, auth_manager);
|
||||
let codex_api_key_env_enabled = model_provider
|
||||
@@ -431,6 +445,7 @@ impl ModelClient {
|
||||
include_timing_metrics,
|
||||
beta_features_header,
|
||||
item_ids_enabled,
|
||||
concurrent_reasoning_summaries_enabled,
|
||||
include_attestation,
|
||||
attestation_provider,
|
||||
disable_websockets: AtomicBool::new(false),
|
||||
@@ -439,6 +454,7 @@ impl ModelClient {
|
||||
}),
|
||||
agent_identity_policy,
|
||||
prompt_cache_key_override: None,
|
||||
http_client_factory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +548,8 @@ impl ModelClient {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let client_setup = self.current_client_setup().await?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let transport =
|
||||
self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?;
|
||||
let request_telemetry = Self::build_request_telemetry(
|
||||
session_telemetry,
|
||||
AuthRequestTelemetryContext::new(
|
||||
@@ -820,7 +837,8 @@ impl ModelClient {
|
||||
responses_metadata: &CodexResponsesMetadata,
|
||||
) -> Result<ResponsesApiRequest> {
|
||||
let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
|
||||
if !self.state.provider.info().is_openai() {
|
||||
let is_openai = self.state.provider.info().is_openai();
|
||||
if !is_openai {
|
||||
input
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
@@ -848,6 +866,10 @@ impl ModelClient {
|
||||
} else {
|
||||
(prompt.base_instructions.text.clone(), Some(tools))
|
||||
};
|
||||
let stream_options = (self.state.concurrent_reasoning_summaries_enabled && is_openai)
|
||||
.then_some(StreamOptions {
|
||||
reasoning_summary_delivery: codex_api::ReasoningSummaryDelivery::SequentialCutoff,
|
||||
});
|
||||
let reasoning = Self::build_reasoning(model_info, effort, summary);
|
||||
let include = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
@@ -882,6 +904,7 @@ impl ModelClient {
|
||||
reasoning,
|
||||
store: provider.is_azure_responses_endpoint(),
|
||||
stream: true,
|
||||
stream_options,
|
||||
include,
|
||||
service_tier,
|
||||
prompt_cache_key,
|
||||
@@ -938,6 +961,21 @@ impl ModelClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_responses_transport(
|
||||
&self,
|
||||
api_provider: &ApiProvider,
|
||||
endpoint: &str,
|
||||
) -> Result<ReqwestTransport> {
|
||||
let request_url = api_provider.url_for_path(endpoint);
|
||||
let client = build_default_reqwest_client_for_route(
|
||||
&self.http_client_factory,
|
||||
&request_url,
|
||||
ClientRouteClass::Api,
|
||||
)
|
||||
.map_err(std::io::Error::from)?;
|
||||
Ok(ReqwestTransport::new(client))
|
||||
}
|
||||
|
||||
pub(crate) async fn prewarm_auth(&self) -> Result<()> {
|
||||
self.current_client_setup().await.map(|_| ())
|
||||
}
|
||||
@@ -1123,42 +1161,48 @@ impl ModelClientSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the current request is an incremental extension of the previous request.
|
||||
/// We only reuse an incremental input delta when non-input request fields are unchanged and
|
||||
/// `input` is a strict extension of the previous known input. Server-returned output items
|
||||
/// are treated as part of the baseline so we do not resend them.
|
||||
fn get_incremental_items(
|
||||
&self,
|
||||
request: &ResponsesApiRequest,
|
||||
last_response: Option<&LastResponse>,
|
||||
allow_empty_delta: bool,
|
||||
) -> Option<Vec<ResponseItem>> {
|
||||
// Checks whether the current request is an incremental extension of the previous request.
|
||||
// We only reuse an incremental input delta when non-input request fields are unchanged and
|
||||
// `input` is a strict
|
||||
// extension of the previous known input. Server-returned output items are treated as part
|
||||
// of the baseline so we do not resend them.
|
||||
let previous_request = self.websocket_session.last_request.as_ref()?;
|
||||
if !responses_request_properties_match(previous_request, request) {
|
||||
trace!("incremental request failed, websocket reuse properties didn't match");
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(after_previous_input) = request
|
||||
.input
|
||||
.strip_prefix(previous_request.input.as_slice())
|
||||
else {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
return None;
|
||||
};
|
||||
let mut response_items =
|
||||
last_response.map_or_else(Vec::new, |response| response.items_added.clone());
|
||||
if !self.client.state.provider.info().is_openai() {
|
||||
response_items
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
// To compare the inputs, we concatenate the previous request items with the response items,
|
||||
// then compare that against the equivalent slice of request items, ignoring metadata. If
|
||||
// they match, we can consider the remaining items the incremental request.
|
||||
let mut previous_items = previous_request.input.clone();
|
||||
if let Some(response) = last_response {
|
||||
previous_items.extend_from_slice(&response.items_added);
|
||||
}
|
||||
let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice())
|
||||
previous_items
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
|
||||
let Some((request_items_to_compare, incremental_items)) =
|
||||
request.input.split_at_checked(previous_items.len())
|
||||
else {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
trace!("incremental request failed, incompatible request length");
|
||||
return None;
|
||||
};
|
||||
let mut request_prefix = request_items_to_compare.to_vec();
|
||||
request_prefix
|
||||
.iter_mut()
|
||||
.for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough);
|
||||
|
||||
if previous_items != request_prefix {
|
||||
trace!("incremental request failed, items didn't match");
|
||||
return None;
|
||||
}
|
||||
if !allow_empty_delta && incremental_items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1366,7 +1410,9 @@ impl ModelClientSession {
|
||||
let mut pending_retry = PendingUnauthorizedRetry::default();
|
||||
loop {
|
||||
let client_setup = self.client.current_client_setup().await?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let transport = self
|
||||
.client
|
||||
.build_responses_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?;
|
||||
let request_auth_context = AuthRequestTelemetryContext::new(
|
||||
client_setup.auth.as_ref().map(CodexAuth::auth_mode),
|
||||
client_setup.api_auth.as_ref(),
|
||||
@@ -1912,6 +1958,7 @@ where
|
||||
let mut logged_error = false;
|
||||
let mut tx_last_response = Some(tx_last_response);
|
||||
let mut items_added: Vec<ResponseItem> = Vec::new();
|
||||
let (request_start, mut ttft_ms) = (Instant::now(), None);
|
||||
let mut api_stream = api_stream;
|
||||
let upstream_request_id = upstream_request_id.as_deref();
|
||||
if let Some(upstream_request_id) = upstream_request_id {
|
||||
@@ -1961,6 +2008,7 @@ where
|
||||
Some(usage.cached_input_tokens),
|
||||
Some(usage.reasoning_output_tokens),
|
||||
usage.total_tokens,
|
||||
ttft_ms,
|
||||
);
|
||||
}
|
||||
inference_trace_attempt.record_completed(
|
||||
@@ -1988,6 +2036,11 @@ where
|
||||
}
|
||||
}
|
||||
Ok(event) => {
|
||||
if matches!(&event, ResponseEvent::OutputItemAdded(_)) && ttft_ms.is_none() {
|
||||
ttft_ms = Some(
|
||||
i64::try_from(request_start.elapsed().as_millis()).unwrap_or(i64::MAX),
|
||||
);
|
||||
}
|
||||
if tx_event.send(Ok(event)).await.is_err() {
|
||||
inference_trace_attempt.record_cancelled(
|
||||
STREAM_DROPPED_REASON,
|
||||
@@ -2088,6 +2141,7 @@ impl AuthRequestTelemetryContext {
|
||||
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::Headers
|
||||
| AuthMode::AgentIdentity
|
||||
| AuthMode::PersonalAccessToken => "Chatgpt",
|
||||
}),
|
||||
|
||||
@@ -116,6 +116,7 @@ fn serializes_text_verbosity_when_set() {
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
stream_options: None,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
service_tier: None,
|
||||
@@ -163,6 +164,7 @@ fn serializes_text_schema_with_strict_format() {
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
stream_options: None,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
service_tier: None,
|
||||
@@ -224,6 +226,7 @@ fn omits_text_when_not_set() {
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
stream_options: None,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
service_tier: None,
|
||||
@@ -247,6 +250,7 @@ fn serializes_flex_service_tier_when_set() {
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
stream_options: None,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
service_tier: Some(ServiceTier::Flex.to_string()),
|
||||
|
||||
@@ -19,6 +19,8 @@ use codex_api::AgentIdentityTelemetry;
|
||||
use codex_api::ApiError;
|
||||
use codex_api::ResponseEvent;
|
||||
use codex_api::TransportError;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::AuthManager;
|
||||
@@ -103,7 +105,9 @@ fn test_model_client_with_thread_id(
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*item_ids_enabled*/ false,
|
||||
/*concurrent_reasoning_summaries_enabled*/ false,
|
||||
/*attestation_provider*/ None,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,7 +151,9 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow::
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*item_ids_enabled*/ false,
|
||||
/*concurrent_reasoning_summaries_enabled*/ false,
|
||||
/*attestation_provider*/ None,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
);
|
||||
let prompt = Prompt {
|
||||
input: vec![ResponseItem::Message {
|
||||
@@ -292,10 +298,7 @@ fn ultra_reasoning_uses_max_for_requests() {
|
||||
super::reasoning_effort_for_request(ReasoningEffort::Ultra),
|
||||
super::reasoning_effort_for_request(ReasoningEffort::High),
|
||||
),
|
||||
(
|
||||
ReasoningEffort::Custom("max".to_string()),
|
||||
ReasoningEffort::High,
|
||||
)
|
||||
(ReasoningEffort::Max, ReasoningEffort::High,)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -824,9 +827,11 @@ fn model_client_with_counting_attestation(
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*item_ids_enabled*/ false,
|
||||
/*concurrent_reasoning_summaries_enabled*/ false,
|
||||
Some(Arc::new(CountingAttestationProvider {
|
||||
calls: attestation_calls.clone(),
|
||||
})),
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
);
|
||||
(model_client, attestation_calls)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
code_mode_session_provider: parent_session.services.code_mode_service.session_provider(),
|
||||
extensions: Arc::clone(&parent_session.services.extensions),
|
||||
conversation_history,
|
||||
requested_history_mode: None,
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
forked_from_thread_id,
|
||||
parent_thread_id: Some(parent_session.thread_id),
|
||||
@@ -298,11 +299,11 @@ async fn forward_events(
|
||||
match event {
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::TokenCount(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
msg:
|
||||
EventMsg::TokenCount(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_),
|
||||
} => {}
|
||||
Event {
|
||||
id,
|
||||
|
||||
@@ -14,6 +14,9 @@ use codex_protocol::protocol::GuardianAssessmentAction;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::McpStartupCompleteEvent;
|
||||
use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -34,8 +37,8 @@ use tokio::sync::watch;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
let (tx_events, rx_events) = bounded(1);
|
||||
async fn forward_events_filters_private_events_before_blocked_send_is_cancelled() {
|
||||
let (tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
@@ -71,32 +74,53 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
cancel.clone(),
|
||||
));
|
||||
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: "evt".to_string(),
|
||||
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
|
||||
item: ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
for msg in [
|
||||
EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
|
||||
server: "pending".to_string(),
|
||||
status: McpStartupStatus::Starting,
|
||||
}),
|
||||
EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()),
|
||||
] {
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: "delegate-startup".to_string(),
|
||||
msg,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let visible_msg = EventMsg::RawResponseItem(RawResponseItemEvent {
|
||||
item: ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
});
|
||||
for id in ["visible-1", "visible-2", "blocked"] {
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: id.to_string(),
|
||||
msg: visible_msg.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
drop(tx_events);
|
||||
let received = rx_out.recv().await.expect("prefilled event missing");
|
||||
assert_eq!(received.id, "full");
|
||||
let received = rx_out.recv().await.expect("visible event missing");
|
||||
assert_eq!(received.id, "visible-1");
|
||||
cancel.cancel();
|
||||
timeout(std::time::Duration::from_millis(1000), forward)
|
||||
.await
|
||||
.expect("forward_events hung")
|
||||
.expect("forward_events join error");
|
||||
|
||||
let received = rx_out.recv().await.expect("prefilled event missing");
|
||||
assert_eq!("full", received.id);
|
||||
let mut ops = Vec::new();
|
||||
while let Ok(sub) = rx_sub.try_recv() {
|
||||
ops.push(sub.op);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::elicitation::ElicitationRegistration;
|
||||
use crate::session::Codex;
|
||||
use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::SteerInputError;
|
||||
@@ -29,6 +30,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -72,6 +74,7 @@ pub struct ThreadConfigSnapshot {
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
pub session_source: SessionSource,
|
||||
pub history_mode: ThreadHistoryMode,
|
||||
pub forked_from_thread_id: Option<ThreadId>,
|
||||
pub parent_thread_id: Option<ThreadId>,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
@@ -160,7 +163,13 @@ pub struct CodexThread {
|
||||
pub(crate) session_source: SessionSource,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
out_of_band_elicitations: Mutex<OutOfBandElicitations>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutOfBandElicitations {
|
||||
count: i64,
|
||||
registration: Option<ElicitationRegistration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -185,7 +194,7 @@ impl CodexThread {
|
||||
session_source,
|
||||
session_configured,
|
||||
rollout_path,
|
||||
out_of_band_elicitation_count: Mutex::new(0),
|
||||
out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,38 +662,30 @@ impl CodexThread {
|
||||
self.codex.enabled(feature)
|
||||
}
|
||||
|
||||
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
|
||||
let mut guard = self.out_of_band_elicitation_count.lock().await;
|
||||
let was_zero = *guard == 0;
|
||||
*guard = guard.checked_add(1).ok_or_else(|| {
|
||||
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
|
||||
let mut elicitations = self.out_of_band_elicitations.lock().await;
|
||||
let incremented = elicitations.count.checked_add(1).ok_or_else(|| {
|
||||
CodexErr::Fatal("out-of-band elicitation count overflowed".to_string())
|
||||
})?;
|
||||
|
||||
if was_zero {
|
||||
self.codex
|
||||
.session
|
||||
.set_out_of_band_elicitation_pause_state(/*paused*/ true);
|
||||
if elicitations.count == 0 {
|
||||
elicitations.registration = Some(self.codex.session.services.elicitations.register());
|
||||
}
|
||||
|
||||
Ok(*guard)
|
||||
elicitations.count = incremented;
|
||||
Ok(incremented)
|
||||
}
|
||||
|
||||
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
|
||||
let mut guard = self.out_of_band_elicitation_count.lock().await;
|
||||
if *guard == 0 {
|
||||
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
|
||||
let mut elicitations = self.out_of_band_elicitations.lock().await;
|
||||
if elicitations.count == 0 {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"out-of-band elicitation count is already zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
*guard -= 1;
|
||||
let now_zero = *guard == 0;
|
||||
if now_zero {
|
||||
self.codex
|
||||
.session
|
||||
.set_out_of_band_elicitation_pause_state(/*paused*/ false);
|
||||
elicitations.count -= 1;
|
||||
if elicitations.count == 0 {
|
||||
elicitations.registration = None;
|
||||
}
|
||||
|
||||
Ok(*guard)
|
||||
Ok(elicitations.count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use codex_analytics::CompactionImplementation;
|
||||
use codex_analytics::CompactionReason;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use tracing::warn;
|
||||
|
||||
pub(crate) fn record_model_fallback(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
previous_model: &str,
|
||||
current_model: &str,
|
||||
reason: CompactionReason,
|
||||
implementation: CompactionImplementation,
|
||||
fallback_error: Option<&CodexErr>,
|
||||
) {
|
||||
let reason_tag = match reason {
|
||||
CompactionReason::UserRequested => "user_requested",
|
||||
CompactionReason::ContextLimit => "context_limit",
|
||||
CompactionReason::ModelDownshift => "model_downshift",
|
||||
CompactionReason::CompHashChanged => "comp_hash_changed",
|
||||
};
|
||||
let implementation_tag = match implementation {
|
||||
CompactionImplementation::Responses => "responses",
|
||||
CompactionImplementation::ResponsesCompactionV2 => "responses_compaction_v2",
|
||||
CompactionImplementation::ResponsesCompact => "responses_compact",
|
||||
};
|
||||
let outcome = if fallback_error.is_none() {
|
||||
"succeeded"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
session_telemetry.counter(
|
||||
"codex.compaction.model_fallback",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("reason", reason_tag),
|
||||
("implementation", implementation_tag),
|
||||
("outcome", outcome),
|
||||
],
|
||||
);
|
||||
warn!(
|
||||
previous_model,
|
||||
current_model,
|
||||
?reason,
|
||||
?implementation,
|
||||
outcome,
|
||||
?fallback_error,
|
||||
"previous-model compaction failed; retried with current model"
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,27 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::Prompt;
|
||||
use crate::client::CompactConversationRequestSettings;
|
||||
use crate::compact::CompactionAnalyticsAttempt;
|
||||
use crate::compact::CompactionAnalyticsDetails;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::compact::build_compaction_initial_context;
|
||||
use crate::compact::compaction_status_from_result;
|
||||
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
|
||||
use crate::compact_model_fallback::record_model_fallback;
|
||||
use crate::context::world_state::WorldState;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::hook_runtime::PostCompactHookOutcome;
|
||||
use crate::hook_runtime::PreCompactHookOutcome;
|
||||
use crate::hook_runtime::run_post_compact_hooks;
|
||||
use crate::hook_runtime::run_pre_compact_hooks;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::CompactionTurnMetadata;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn::built_tools;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_analytics::CompactionImplementation;
|
||||
use codex_analytics::CompactionPhase;
|
||||
use codex_analytics::CompactionReason;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::auth::AuthMode;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
@@ -38,8 +34,11 @@ use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_rollout_trace::CompactionCheckpointTracePayload;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
#[path = "compact_remote_request.rs"]
|
||||
mod request;
|
||||
use request::RemoteCompactAttempt;
|
||||
use request::run_remote_compact_attempt;
|
||||
|
||||
const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
|
||||
"Output exceeded the available model context and was truncated";
|
||||
@@ -47,19 +46,25 @@ const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
|
||||
pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
step_context: Arc<StepContext>,
|
||||
fallback_step_context: Option<Arc<StepContext>>,
|
||||
turn_state: Arc<OnceLock<String>>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
CompactionTrigger::Auto,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompact,
|
||||
phase,
|
||||
);
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&step_context,
|
||||
fallback_step_context.as_ref(),
|
||||
Some(turn_state),
|
||||
initial_context_injection,
|
||||
CompactionTrigger::Auto,
|
||||
reason,
|
||||
phase,
|
||||
compaction_metadata,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -80,14 +85,19 @@ pub(crate) async fn run_remote_compact_task(
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionImplementation::ResponsesCompact,
|
||||
CompactionPhase::StandaloneTurn,
|
||||
);
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&step_context,
|
||||
/*fallback_step_context*/ None,
|
||||
/*turn_state*/ None,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionPhase::StandaloneTurn,
|
||||
compaction_metadata,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -96,19 +106,16 @@ pub(crate) async fn run_remote_compact_task(
|
||||
async fn run_remote_compact_task_inner(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
fallback_step_context: Option<&Arc<StepContext>>,
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
trigger: CompactionTrigger,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
) -> CodexResult<()> {
|
||||
let turn_context = &step_context.turn;
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompact,
|
||||
phase,
|
||||
);
|
||||
let trigger = compaction_metadata.trigger();
|
||||
let reason = compaction_metadata.reason();
|
||||
let implementation = compaction_metadata.implementation();
|
||||
let phase = compaction_metadata.phase();
|
||||
let mut analytics_details = CompactionAnalyticsDetails {
|
||||
active_context_tokens_before: Some(sess.get_total_token_usage().await),
|
||||
..Default::default()
|
||||
@@ -118,7 +125,7 @@ async fn run_remote_compact_task_inner(
|
||||
turn_context.as_ref(),
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompact,
|
||||
implementation,
|
||||
phase,
|
||||
)
|
||||
.await;
|
||||
@@ -141,6 +148,7 @@ async fn run_remote_compact_task_inner(
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
turn_state,
|
||||
initial_context_injection,
|
||||
compaction_metadata,
|
||||
@@ -175,6 +183,7 @@ async fn run_remote_compact_task_inner(
|
||||
async fn run_remote_compact_task_inner_impl(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
fallback_step_context: Option<&Arc<StepContext>>,
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
@@ -182,93 +191,75 @@ async fn run_remote_compact_task_inner_impl(
|
||||
) -> CodexResult<()> {
|
||||
let turn_context = &step_context.turn;
|
||||
let context_compaction_item = ContextCompactionItem::new();
|
||||
let compaction_id = context_compaction_item.id.clone();
|
||||
// Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events,
|
||||
// endpoint attempts, and the installed history checkpoint all have one join key.
|
||||
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
|
||||
turn_context.sub_id.as_str(),
|
||||
context_compaction_item.id.as_str(),
|
||||
compaction_id.as_str(),
|
||||
turn_context.model_info.slug.as_str(),
|
||||
turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
let compaction_item = TurnItem::ContextCompaction(context_compaction_item);
|
||||
sess.emit_turn_item_started(turn_context, &compaction_item)
|
||||
.await;
|
||||
let mut history = sess.clone_history().await;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let (rewritten_outputs, estimated_deleted_tokens) =
|
||||
trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
turn_context.as_ref(),
|
||||
&base_instructions,
|
||||
);
|
||||
if rewritten_outputs > 0 {
|
||||
info!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
rewritten_outputs,
|
||||
"rewrote history outputs before remote compaction"
|
||||
);
|
||||
}
|
||||
if estimated_deleted_tokens > 0 {
|
||||
let max_local_deleted_tokens = sess
|
||||
.estimated_tokens_after_last_model_generated_item()
|
||||
.await;
|
||||
analytics_details.active_context_tokens_before = analytics_details
|
||||
.active_context_tokens_before
|
||||
.map(|active_context_tokens_before| {
|
||||
active_context_tokens_before
|
||||
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
|
||||
});
|
||||
}
|
||||
// This is the history selected for remote compaction, after any output rewriting required to
|
||||
// fit the compact endpoint. The checkpoint below records it separately from the next sampling
|
||||
// request, whose prompt will repeat current developer/context prefix items.
|
||||
let trace_input_history = history.raw_items().to_vec();
|
||||
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
step_context.as_ref(),
|
||||
&CancellationToken::new(),
|
||||
let attempt = run_remote_compact_attempt(
|
||||
sess,
|
||||
step_context,
|
||||
turn_state.clone(),
|
||||
&compaction_trace,
|
||||
compaction_metadata,
|
||||
analytics_details,
|
||||
)
|
||||
.await?;
|
||||
let prompt = Prompt {
|
||||
input: prompt_input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
base_instructions,
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
.await;
|
||||
let (attempt, compaction_turn_context) = match attempt {
|
||||
Ok(attempt) => (attempt, turn_context),
|
||||
Err(error) => {
|
||||
let Some(fallback_step_context) = fallback_step_context else {
|
||||
return Err(error);
|
||||
};
|
||||
if !matches!(&error, CodexErr::InvalidRequest(_)) {
|
||||
return Err(error);
|
||||
}
|
||||
let fallback_turn_context = &fallback_step_context.turn;
|
||||
let fallback_compaction_trace =
|
||||
sess.services.rollout_thread_trace.compaction_trace_context(
|
||||
fallback_turn_context.sub_id.as_str(),
|
||||
compaction_id.as_str(),
|
||||
fallback_turn_context.model_info.slug.as_str(),
|
||||
fallback_turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
let fallback_result = run_remote_compact_attempt(
|
||||
sess,
|
||||
fallback_step_context,
|
||||
turn_state,
|
||||
&fallback_compaction_trace,
|
||||
compaction_metadata,
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
record_model_fallback(
|
||||
&sess.services.session_telemetry,
|
||||
turn_context.model_info.slug.as_str(),
|
||||
fallback_turn_context.model_info.slug.as_str(),
|
||||
compaction_metadata.reason(),
|
||||
compaction_metadata.implementation(),
|
||||
fallback_result.as_ref().err(),
|
||||
);
|
||||
match fallback_result {
|
||||
Ok(attempt) => (attempt, fallback_turn_context),
|
||||
Err(_) => return Err(error),
|
||||
}
|
||||
}
|
||||
};
|
||||
let window_id = sess.current_window_id().await;
|
||||
let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata(
|
||||
sess.installation_id.clone(),
|
||||
window_id,
|
||||
CodexResponsesRequestKind::Compaction(compaction_metadata),
|
||||
);
|
||||
let new_history = sess
|
||||
.services
|
||||
.model_client
|
||||
.compact_conversation_history(
|
||||
&prompt,
|
||||
&turn_context.model_info,
|
||||
turn_state,
|
||||
CompactConversationRequestSettings {
|
||||
effort: turn_context.reasoning_effort.clone(),
|
||||
summary: turn_context.reasoning_summary,
|
||||
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
|
||||
None
|
||||
} else {
|
||||
turn_context.config.service_tier.clone()
|
||||
},
|
||||
},
|
||||
&turn_context.session_telemetry,
|
||||
&compaction_trace,
|
||||
&responses_metadata,
|
||||
)
|
||||
.await?;
|
||||
let RemoteCompactAttempt {
|
||||
new_history,
|
||||
trace_input_history,
|
||||
} = attempt;
|
||||
let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await;
|
||||
let (new_history, world_state_baseline) = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
compaction_turn_context.as_ref(),
|
||||
new_history,
|
||||
&initial_context_injection,
|
||||
)
|
||||
@@ -277,7 +268,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage(_) => {
|
||||
Some(turn_context.to_turn_context_item())
|
||||
Some(compaction_turn_context.to_turn_context_item())
|
||||
}
|
||||
};
|
||||
let compacted_item = CompactedItem {
|
||||
@@ -296,16 +287,16 @@ async fn run_remote_compact_task_inner_impl(
|
||||
replacement_history: &new_history,
|
||||
});
|
||||
sess.replace_compacted_history(
|
||||
turn_context.as_ref(),
|
||||
compaction_turn_context.as_ref(),
|
||||
new_history,
|
||||
reference_context_item,
|
||||
world_state_baseline,
|
||||
compacted_item,
|
||||
)
|
||||
.await;
|
||||
sess.recompute_token_usage(turn_context).await;
|
||||
sess.recompute_token_usage(compaction_turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(turn_context, compaction_item)
|
||||
sess.emit_turn_item_completed(compaction_turn_context, compaction_item)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::trim_function_call_history_to_fit_context_window;
|
||||
use crate::Prompt;
|
||||
use crate::client::CompactConversationRequestSettings;
|
||||
use crate::compact::CompactionAnalyticsDetails;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::CompactionTurnMetadata;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn::built_tools;
|
||||
use codex_protocol::auth::AuthMode;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_rollout_trace::CompactionTraceContext;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
pub(super) struct RemoteCompactAttempt {
|
||||
pub(super) new_history: Vec<ResponseItem>,
|
||||
pub(super) trace_input_history: Vec<ResponseItem>,
|
||||
}
|
||||
|
||||
pub(super) async fn run_remote_compact_attempt(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
compaction_trace: &CompactionTraceContext,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
analytics_details: &mut CompactionAnalyticsDetails,
|
||||
) -> CodexResult<RemoteCompactAttempt> {
|
||||
let turn_context = &step_context.turn;
|
||||
let mut history = sess.clone_history().await;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let (rewritten_outputs, estimated_deleted_tokens) =
|
||||
trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
turn_context.as_ref(),
|
||||
&base_instructions,
|
||||
);
|
||||
if rewritten_outputs > 0 {
|
||||
info!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
rewritten_outputs,
|
||||
"rewrote history outputs before remote compaction"
|
||||
);
|
||||
}
|
||||
if estimated_deleted_tokens > 0 {
|
||||
let max_local_deleted_tokens = sess
|
||||
.estimated_tokens_after_last_model_generated_item()
|
||||
.await;
|
||||
analytics_details.active_context_tokens_before = analytics_details
|
||||
.active_context_tokens_before
|
||||
.map(|active_context_tokens_before| {
|
||||
active_context_tokens_before
|
||||
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
|
||||
});
|
||||
}
|
||||
let trace_input_history = history.raw_items().to_vec();
|
||||
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
step_context.as_ref(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let prompt = Prompt {
|
||||
input: prompt_input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
base_instructions,
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
};
|
||||
let window_id = sess.current_window_id().await;
|
||||
let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata(
|
||||
sess.installation_id.clone(),
|
||||
window_id,
|
||||
CodexResponsesRequestKind::Compaction(compaction_metadata),
|
||||
);
|
||||
let new_history = sess
|
||||
.services
|
||||
.model_client
|
||||
.compact_conversation_history(
|
||||
&prompt,
|
||||
&turn_context.model_info,
|
||||
turn_state,
|
||||
CompactConversationRequestSettings {
|
||||
effort: turn_context.reasoning_effort.clone(),
|
||||
summary: turn_context.reasoning_summary,
|
||||
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
|
||||
None
|
||||
} else {
|
||||
turn_context.config.service_tier.clone()
|
||||
},
|
||||
},
|
||||
&turn_context.session_telemetry,
|
||||
compaction_trace,
|
||||
&responses_metadata,
|
||||
)
|
||||
.await?;
|
||||
Ok(RemoteCompactAttempt {
|
||||
new_history,
|
||||
trace_input_history,
|
||||
})
|
||||
}
|
||||
@@ -8,21 +8,19 @@ use crate::compact::CompactionAnalyticsAttempt;
|
||||
use crate::compact::CompactionAnalyticsDetails;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::compact::compaction_status_from_result;
|
||||
use crate::compact_model_fallback::record_model_fallback;
|
||||
use crate::compact_remote::process_compacted_history;
|
||||
use crate::compact_remote::should_keep_compacted_history_item;
|
||||
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
|
||||
use crate::hook_runtime::PostCompactHookOutcome;
|
||||
use crate::hook_runtime::PreCompactHookOutcome;
|
||||
use crate::hook_runtime::run_post_compact_hooks;
|
||||
use crate::hook_runtime::run_pre_compact_hooks;
|
||||
use crate::responses_metadata::CodexResponsesMetadata;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::CompactionTurnMetadata;
|
||||
use crate::responses_retry::ResponsesStreamRequest;
|
||||
use crate::responses_retry::handle_retryable_response_stream_error;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn::built_tools;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_analytics::CompactionImplementation;
|
||||
use codex_analytics::CompactionPhase;
|
||||
@@ -44,8 +42,11 @@ use codex_rollout_trace::InferenceTraceContext;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use futures::StreamExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
#[path = "compact_remote_v2_attempt.rs"]
|
||||
mod attempt;
|
||||
use attempt::RemoteCompactV2Attempt;
|
||||
use attempt::run_remote_compact_v2_attempt;
|
||||
|
||||
// Mirror the current /responses/compact retained-message default while the
|
||||
// server-side path remains the reference implementation.
|
||||
@@ -57,19 +58,25 @@ const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2;
|
||||
pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
step_context: Arc<StepContext>,
|
||||
fallback_step_context: Option<Arc<StepContext>>,
|
||||
client_session: &mut ModelClientSession,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
CompactionTrigger::Auto,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompactionV2,
|
||||
phase,
|
||||
);
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&step_context,
|
||||
fallback_step_context.as_ref(),
|
||||
Some(client_session),
|
||||
initial_context_injection,
|
||||
CompactionTrigger::Auto,
|
||||
reason,
|
||||
phase,
|
||||
compaction_metadata,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -89,14 +96,19 @@ pub(crate) async fn run_remote_compact_task(
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionImplementation::ResponsesCompactionV2,
|
||||
CompactionPhase::StandaloneTurn,
|
||||
);
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&step_context,
|
||||
/*fallback_step_context*/ None,
|
||||
/*client_session*/ None,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionPhase::StandaloneTurn,
|
||||
compaction_metadata,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -104,19 +116,16 @@ pub(crate) async fn run_remote_compact_task(
|
||||
async fn run_remote_compact_task_inner(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
fallback_step_context: Option<&Arc<StepContext>>,
|
||||
client_session: Option<&mut ModelClientSession>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
trigger: CompactionTrigger,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
) -> CodexResult<()> {
|
||||
let turn_context = &step_context.turn;
|
||||
let compaction_metadata = CompactionTurnMetadata::new(
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompactionV2,
|
||||
phase,
|
||||
);
|
||||
let trigger = compaction_metadata.trigger();
|
||||
let reason = compaction_metadata.reason();
|
||||
let implementation = compaction_metadata.implementation();
|
||||
let phase = compaction_metadata.phase();
|
||||
let mut analytics_details = CompactionAnalyticsDetails {
|
||||
active_context_tokens_before: Some(sess.get_total_token_usage().await),
|
||||
..Default::default()
|
||||
@@ -126,7 +135,7 @@ async fn run_remote_compact_task_inner(
|
||||
turn_context.as_ref(),
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::ResponsesCompactionV2,
|
||||
implementation,
|
||||
phase,
|
||||
)
|
||||
.await;
|
||||
@@ -149,6 +158,7 @@ async fn run_remote_compact_task_inner(
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
client_session,
|
||||
initial_context_injection,
|
||||
compaction_metadata,
|
||||
@@ -186,16 +196,18 @@ async fn run_remote_compact_task_inner(
|
||||
async fn run_remote_compact_task_inner_impl(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
client_session: Option<&mut ModelClientSession>,
|
||||
fallback_step_context: Option<&Arc<StepContext>>,
|
||||
mut client_session: Option<&mut ModelClientSession>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
analytics_details: &mut CompactionAnalyticsDetails,
|
||||
) -> CodexResult<()> {
|
||||
let turn_context = &step_context.turn;
|
||||
let context_compaction_item = ContextCompactionItem::new();
|
||||
let compaction_id = context_compaction_item.id.clone();
|
||||
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
|
||||
turn_context.sub_id.as_str(),
|
||||
context_compaction_item.id.as_str(),
|
||||
compaction_id.as_str(),
|
||||
turn_context.model_info.slug.as_str(),
|
||||
turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
@@ -203,91 +215,62 @@ async fn run_remote_compact_task_inner_impl(
|
||||
sess.emit_turn_item_started(turn_context, &compaction_item)
|
||||
.await;
|
||||
|
||||
let mut history = sess.clone_history().await;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let (rewritten_outputs, estimated_deleted_tokens) =
|
||||
trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
turn_context.as_ref(),
|
||||
&base_instructions,
|
||||
);
|
||||
if rewritten_outputs > 0 {
|
||||
info!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
rewritten_outputs,
|
||||
"rewrote history outputs before remote compaction v2"
|
||||
);
|
||||
}
|
||||
if estimated_deleted_tokens > 0 {
|
||||
let max_local_deleted_tokens = sess
|
||||
.estimated_tokens_after_last_model_generated_item()
|
||||
.await;
|
||||
analytics_details.active_context_tokens_before = analytics_details
|
||||
.active_context_tokens_before
|
||||
.map(|active_context_tokens_before| {
|
||||
active_context_tokens_before
|
||||
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
|
||||
});
|
||||
}
|
||||
|
||||
let trace_input_history = history.raw_items().to_vec();
|
||||
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
step_context.as_ref(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let mut input = prompt_input.clone();
|
||||
input.push(ResponseItem::CompactionTrigger {});
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
base_instructions,
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
};
|
||||
|
||||
let window_id = sess.current_window_id().await;
|
||||
let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata(
|
||||
sess.installation_id.clone(),
|
||||
window_id,
|
||||
CodexResponsesRequestKind::Compaction(compaction_metadata),
|
||||
);
|
||||
let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({
|
||||
"model": turn_context.model_info.slug.as_str(),
|
||||
"instructions": prompt.base_instructions.text.as_str(),
|
||||
"input": &prompt.input,
|
||||
"parallel_tool_calls": prompt.parallel_tool_calls,
|
||||
}));
|
||||
|
||||
let mut owned_client_session;
|
||||
let client_session = match client_session {
|
||||
Some(client_session) => client_session,
|
||||
None => {
|
||||
owned_client_session = sess.services.model_client.new_session();
|
||||
&mut owned_client_session
|
||||
}
|
||||
};
|
||||
let compaction_output_result = run_remote_compaction_request_v2(
|
||||
let attempt = run_remote_compact_v2_attempt(
|
||||
sess,
|
||||
turn_context,
|
||||
client_session,
|
||||
&prompt,
|
||||
&responses_metadata,
|
||||
step_context,
|
||||
client_session.as_deref_mut(),
|
||||
&compaction_trace,
|
||||
compaction_metadata,
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
|
||||
trace_attempt.record_result(
|
||||
compaction_output_result
|
||||
.as_ref()
|
||||
.map(|output| std::slice::from_ref(&output.compaction_output)),
|
||||
);
|
||||
let RemoteCompactionV2Output {
|
||||
let (attempt, compaction_turn_context) = match attempt {
|
||||
Ok(attempt) => (attempt, turn_context),
|
||||
Err(error) => {
|
||||
let Some(fallback_step_context) = fallback_step_context else {
|
||||
return Err(error);
|
||||
};
|
||||
if !matches!(&error, CodexErr::InvalidRequest(_)) {
|
||||
return Err(error);
|
||||
}
|
||||
let fallback_turn_context = &fallback_step_context.turn;
|
||||
let fallback_compaction_trace =
|
||||
sess.services.rollout_thread_trace.compaction_trace_context(
|
||||
fallback_turn_context.sub_id.as_str(),
|
||||
compaction_id.as_str(),
|
||||
fallback_turn_context.model_info.slug.as_str(),
|
||||
fallback_turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
let fallback_result = run_remote_compact_v2_attempt(
|
||||
sess,
|
||||
fallback_step_context,
|
||||
client_session,
|
||||
&fallback_compaction_trace,
|
||||
compaction_metadata,
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
record_model_fallback(
|
||||
&sess.services.session_telemetry,
|
||||
turn_context.model_info.slug.as_str(),
|
||||
fallback_turn_context.model_info.slug.as_str(),
|
||||
compaction_metadata.reason(),
|
||||
compaction_metadata.implementation(),
|
||||
fallback_result.as_ref().err(),
|
||||
);
|
||||
match fallback_result {
|
||||
Ok(attempt) => (attempt, fallback_turn_context),
|
||||
Err(_) => return Err(error),
|
||||
}
|
||||
}
|
||||
};
|
||||
let RemoteCompactV2Attempt {
|
||||
trace_input_history,
|
||||
prompt_input,
|
||||
compaction_output,
|
||||
token_usage,
|
||||
} = compaction_output_result?;
|
||||
owned_client_session: _owned_client_session,
|
||||
} = attempt;
|
||||
if let Some(token_usage) = token_usage {
|
||||
sess.record_rollout_budget_usage(&token_usage)?;
|
||||
analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
|
||||
@@ -300,7 +283,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await;
|
||||
let (new_history, world_state_baseline) = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
compaction_turn_context.as_ref(),
|
||||
compacted_history,
|
||||
&initial_context_injection,
|
||||
)
|
||||
@@ -309,7 +292,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage(_) => {
|
||||
Some(turn_context.to_turn_context_item())
|
||||
Some(compaction_turn_context.to_turn_context_item())
|
||||
}
|
||||
};
|
||||
let compacted_item = CompactedItem {
|
||||
@@ -325,16 +308,16 @@ async fn run_remote_compact_task_inner_impl(
|
||||
replacement_history: &new_history,
|
||||
});
|
||||
sess.replace_compacted_history(
|
||||
turn_context.as_ref(),
|
||||
compaction_turn_context.as_ref(),
|
||||
new_history,
|
||||
reference_context_item,
|
||||
world_state_baseline,
|
||||
compacted_item,
|
||||
)
|
||||
.await;
|
||||
sess.recompute_token_usage(turn_context).await;
|
||||
sess.recompute_token_usage(compaction_turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(turn_context, compaction_item)
|
||||
sess.emit_turn_item_completed(compaction_turn_context, compaction_item)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::RemoteCompactionV2Output;
|
||||
use super::run_remote_compaction_request_v2;
|
||||
use crate::Prompt;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::compact::CompactionAnalyticsDetails;
|
||||
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::CompactionTurnMetadata;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn::built_tools;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_rollout_trace::CompactionTraceContext;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
pub(super) struct RemoteCompactV2Attempt {
|
||||
pub(super) trace_input_history: Vec<ResponseItem>,
|
||||
pub(super) prompt_input: Vec<ResponseItem>,
|
||||
pub(super) compaction_output: ResponseItem,
|
||||
pub(super) token_usage: Option<TokenUsage>,
|
||||
/// Keeps a session created for standalone compaction alive through lifecycle completion.
|
||||
pub(super) owned_client_session: Option<ModelClientSession>,
|
||||
}
|
||||
|
||||
pub(super) async fn run_remote_compact_v2_attempt(
|
||||
sess: &Arc<Session>,
|
||||
step_context: &Arc<StepContext>,
|
||||
client_session: Option<&mut ModelClientSession>,
|
||||
compaction_trace: &CompactionTraceContext,
|
||||
compaction_metadata: CompactionTurnMetadata,
|
||||
analytics_details: &mut CompactionAnalyticsDetails,
|
||||
) -> CodexResult<RemoteCompactV2Attempt> {
|
||||
let turn_context = &step_context.turn;
|
||||
let mut history = sess.clone_history().await;
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let (rewritten_outputs, estimated_deleted_tokens) =
|
||||
trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
turn_context.as_ref(),
|
||||
&base_instructions,
|
||||
);
|
||||
if rewritten_outputs > 0 {
|
||||
info!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
rewritten_outputs,
|
||||
"rewrote history outputs before remote compaction v2"
|
||||
);
|
||||
}
|
||||
if estimated_deleted_tokens > 0 {
|
||||
let max_local_deleted_tokens = sess
|
||||
.estimated_tokens_after_last_model_generated_item()
|
||||
.await;
|
||||
analytics_details.active_context_tokens_before = analytics_details
|
||||
.active_context_tokens_before
|
||||
.map(|active_context_tokens_before| {
|
||||
active_context_tokens_before
|
||||
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
|
||||
});
|
||||
}
|
||||
|
||||
let trace_input_history = history.raw_items().to_vec();
|
||||
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
||||
let tool_router = built_tools(
|
||||
sess.as_ref(),
|
||||
step_context.as_ref(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await?;
|
||||
let mut input = prompt_input.clone();
|
||||
input.push(ResponseItem::CompactionTrigger {});
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
base_instructions,
|
||||
output_schema: None,
|
||||
output_schema_strict: true,
|
||||
};
|
||||
|
||||
let window_id = sess.current_window_id().await;
|
||||
let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata(
|
||||
sess.installation_id.clone(),
|
||||
window_id,
|
||||
CodexResponsesRequestKind::Compaction(compaction_metadata),
|
||||
);
|
||||
let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({
|
||||
"model": turn_context.model_info.slug.as_str(),
|
||||
"instructions": prompt.base_instructions.text.as_str(),
|
||||
"input": &prompt.input,
|
||||
"parallel_tool_calls": prompt.parallel_tool_calls,
|
||||
}));
|
||||
let mut owned_client_session = None;
|
||||
let client_session = match client_session {
|
||||
Some(client_session) => client_session,
|
||||
None => owned_client_session.insert(sess.services.model_client.new_session()),
|
||||
};
|
||||
let compaction_output_result = run_remote_compaction_request_v2(
|
||||
sess,
|
||||
turn_context.as_ref(),
|
||||
client_session,
|
||||
&prompt,
|
||||
&responses_metadata,
|
||||
)
|
||||
.await;
|
||||
trace_attempt.record_result(
|
||||
compaction_output_result
|
||||
.as_ref()
|
||||
.map(|output| std::slice::from_ref(&output.compaction_output)),
|
||||
);
|
||||
let RemoteCompactionV2Output {
|
||||
compaction_output,
|
||||
token_usage,
|
||||
} = compaction_output_result?;
|
||||
Ok(RemoteCompactV2Attempt {
|
||||
trace_input_history,
|
||||
prompt_input,
|
||||
compaction_output,
|
||||
token_usage,
|
||||
owned_client_session,
|
||||
})
|
||||
}
|
||||
@@ -1590,6 +1590,10 @@ respect_system_proxy = true
|
||||
.await?;
|
||||
|
||||
assert!(config.respect_system_proxy);
|
||||
assert_eq!(
|
||||
config.http_client_factory().outbound_proxy_policy(),
|
||||
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6035,13 +6039,6 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
assert_eq!(
|
||||
mcp_config.client_elicitation_capability,
|
||||
ElicitationCapability::default()
|
||||
);
|
||||
|
||||
let _ = config.features.enable(Feature::AuthElicitation);
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
assert_eq!(
|
||||
mcp_config.client_elicitation_capability,
|
||||
@@ -6051,6 +6048,13 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::
|
||||
}
|
||||
);
|
||||
|
||||
let _ = config.features.disable(Feature::AuthElicitation);
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
assert_eq!(
|
||||
mcp_config.client_elicitation_capability,
|
||||
ElicitationCapability::default()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8867,6 +8871,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
permissions: None,
|
||||
models: None,
|
||||
guardian_policy_config: None,
|
||||
};
|
||||
let requirement_source = codex_config::RequirementSource::Unknown;
|
||||
@@ -10306,6 +10311,7 @@ default_wait_timeout_ms = 30000
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
multi_agent_mode_hint_text = "Custom mode guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
@@ -10342,6 +10348,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("Subagent guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(),
|
||||
Some("Custom mode guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("agents")
|
||||
@@ -10404,6 +10414,22 @@ max_concurrent_threads_per_session = 17
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_v2_preserves_empty_mode_hint_override() {
|
||||
let config_toml = toml::from_str(
|
||||
r#"[features.multi_agent_v2]
|
||||
multi_agent_mode_hint_text = ""
|
||||
"#,
|
||||
)
|
||||
.expect("multi-agent v2 config should parse");
|
||||
|
||||
let expected = MultiAgentV2Config {
|
||||
multi_agent_mode_hint_text: Some(String::new()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -121,6 +121,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
|
||||
entry["default_tools_approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Writes => "writes",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
@@ -173,6 +174,7 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
|
||||
entry["approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Writes => "writes",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ use codex_config::types::TuiNotificationSettings;
|
||||
use codex_config::types::TuiPetAnchor;
|
||||
use codex_config::types::UriBasedFileOpener;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_plugins::PluginLoadOutcome;
|
||||
use codex_core_plugins::PluginsConfigInput;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
@@ -69,6 +70,8 @@ use codex_features::MultiAgentV2ConfigToml;
|
||||
use codex_features::NetworkProxyConfigToml;
|
||||
use codex_features::TokenBudgetConfigToml;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_login::AuthManagerConfig;
|
||||
use codex_login::AuthRouteConfig;
|
||||
@@ -1144,6 +1147,7 @@ pub struct MultiAgentV2Config {
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
pub multi_agent_mode_hint_text: Option<String>,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub non_code_mode_only: bool,
|
||||
@@ -1165,6 +1169,7 @@ impl MultiAgentV2Config {
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
multi_agent_mode_hint_text: None,
|
||||
tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()),
|
||||
hide_spawn_agent_metadata: true,
|
||||
non_code_mode_only: true,
|
||||
@@ -1478,6 +1483,16 @@ impl Config {
|
||||
.then(AuthRouteConfig::respect_system_proxy)
|
||||
}
|
||||
|
||||
/// Creates the HTTP client factory resolved from the effective feature configuration.
|
||||
pub fn http_client_factory(&self) -> HttpClientFactory {
|
||||
let outbound_proxy_policy = if self.respect_system_proxy {
|
||||
OutboundProxyPolicy::RespectSystemProxy
|
||||
} else {
|
||||
OutboundProxyPolicy::ReqwestDefault
|
||||
};
|
||||
HttpClientFactory::new(outbound_proxy_policy)
|
||||
}
|
||||
|
||||
/// Build the plugin-manager input from the effective config.
|
||||
pub fn plugins_config_input(&self) -> PluginsConfigInput {
|
||||
PluginsConfigInput::new(
|
||||
@@ -1526,6 +1541,14 @@ impl Config {
|
||||
) -> McpConfig {
|
||||
let plugins_input = self.plugins_config_input();
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
self.to_mcp_config_with_loaded_plugins(&loaded_plugins, additional_plugin_registrations)
|
||||
}
|
||||
|
||||
pub(crate) fn to_mcp_config_with_loaded_plugins(
|
||||
&self,
|
||||
loaded_plugins: &PluginLoadOutcome,
|
||||
additional_plugin_registrations: impl IntoIterator<Item = McpServerRegistration>,
|
||||
) -> McpConfig {
|
||||
let mut catalog = ResolvedMcpCatalog::builder();
|
||||
for (plugin_order, plugin) in loaded_plugins
|
||||
.plugins()
|
||||
@@ -2501,6 +2524,10 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
base.map(|config| &config.subagent_usage_hint_text),
|
||||
default.subagent_usage_hint_text,
|
||||
);
|
||||
let multi_agent_mode_hint_text = base
|
||||
.and_then(|config| config.multi_agent_mode_hint_text.as_ref())
|
||||
.cloned()
|
||||
.or(default.multi_agent_mode_hint_text);
|
||||
let tool_namespace = base
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.cloned()
|
||||
@@ -2520,6 +2547,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
usage_hint_text,
|
||||
root_agent_usage_hint_text,
|
||||
subagent_usage_hint_text,
|
||||
multi_agent_mode_hint_text,
|
||||
tool_namespace,
|
||||
hide_spawn_agent_metadata,
|
||||
non_code_mode_only,
|
||||
|
||||
@@ -80,19 +80,6 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
|
||||
mcp_connection_manager: &McpConnectionManager,
|
||||
config: &Config,
|
||||
) -> Vec<AppInfo> {
|
||||
with_app_enabled_state(
|
||||
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
|
||||
config,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
@@ -283,6 +270,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::connectors::AppInfo;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
@@ -8,15 +7,6 @@ use super::ContextualUserFragment;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AppsInstructions;
|
||||
|
||||
impl AppsInstructions {
|
||||
pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option<Self> {
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.then_some(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AppsInstructions {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AvailablePluginsInstructions {
|
||||
plugins: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl AvailablePluginsInstructions {
|
||||
pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option<Self> {
|
||||
if plugins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
plugins: plugins.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) struct AvailablePluginsInstructions;
|
||||
|
||||
impl ContextualUserFragment for AvailablePluginsInstructions {
|
||||
fn role(&self) -> &'static str {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use codex_core_skills::AvailableSkills;
|
||||
use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS;
|
||||
use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ALIASES;
|
||||
use codex_core_skills::render_available_skills_body;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
@@ -20,13 +22,24 @@ impl AvailableSkillsInstructions {
|
||||
skill_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
fn from(available_skills: AvailableSkills) -> Self {
|
||||
pub fn from_available_skills(
|
||||
available_skills: &AvailableSkills,
|
||||
include_skills_usage_instructions: bool,
|
||||
) -> Self {
|
||||
let mut skill_lines = available_skills.skill_lines.clone();
|
||||
if include_skills_usage_instructions {
|
||||
skill_lines.push("### How to use skills".to_string());
|
||||
let instructions = if available_skills.skill_root_lines.is_empty() {
|
||||
SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS
|
||||
} else {
|
||||
SKILLS_HOW_TO_USE_WITH_ALIASES
|
||||
};
|
||||
skill_lines.push(instructions.to_string());
|
||||
}
|
||||
Self {
|
||||
skill_root_lines: available_skills.skill_root_lines,
|
||||
skill_lines: available_skills.skill_lines,
|
||||
skill_root_lines: available_skills.skill_root_lines.clone(),
|
||||
skill_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProce
|
||||
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
|
||||
pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions;
|
||||
pub(crate) use network_rule_saved::NetworkRuleSaved;
|
||||
pub use permissions_instructions::ApprovalPromptContext;
|
||||
pub use permissions_instructions::PermissionsInstructions;
|
||||
pub(crate) use personality_spec_instructions::PersonalitySpecInstructions;
|
||||
pub(crate) use plugin_instructions::PluginInstructions;
|
||||
|
||||
@@ -3,8 +3,7 @@ use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
|
||||
const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work.";
|
||||
const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies.";
|
||||
const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
|
||||
const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -32,8 +31,8 @@ impl ContextualUserFragment for MultiAgentModeInstructions {
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
match self.multi_agent_mode {
|
||||
MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(),
|
||||
match &self.multi_agent_mode {
|
||||
MultiAgentMode::Custom(hint_text) => hint_text.clone(),
|
||||
MultiAgentMode::ExplicitRequestOnly => {
|
||||
EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string()
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub use codex_prompts::ApprovalPromptContext;
|
||||
pub use codex_prompts::PermissionsInstructions;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::AppsInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
|
||||
/// Whether generic Apps usage guidance should be visible to the model.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct AppsInstructionsState {
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl AppsInstructionsState {
|
||||
pub(crate) fn new(available: bool) -> Self {
|
||||
Self { available }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for AppsInstructionsState {
|
||||
const ID: &'static str = "apps_instructions";
|
||||
type Snapshot = bool;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
|
||||
role == "developer" && AppsInstructions::matches_text(text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
if !self.available
|
||||
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|
||||
|| matches!(previous, PreviousSectionState::Unknown)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Box::new(AppsInstructions))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "apps_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn render(state: AppsInstructionsState, previous: PreviousSectionState<'_, bool>) -> Vec<String> {
|
||||
state
|
||||
.render_diff(previous)
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.render())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_only_when_apps_become_available() {
|
||||
let unavailable = AppsInstructionsState::new(/*available*/ false);
|
||||
let available = AppsInstructionsState::new(/*available*/ true);
|
||||
let false_snapshot = false;
|
||||
let true_snapshot = true;
|
||||
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Absent),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_guidance_is_not_injected_again() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
|
||||
let legacy: ResponseItem = ContextualUserFragment::into(AppsInstructions);
|
||||
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(/*previous*/ None, &[legacy])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_guidance_is_restored_only_when_missing_from_history() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
|
||||
let snapshot = world_state.snapshot();
|
||||
let retained: ResponseItem = ContextualUserFragment::into(AppsInstructions);
|
||||
|
||||
assert_eq!(
|
||||
world_state.render_history_diff(Some(&snapshot), &[]).len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(Some(&snapshot), &[retained])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod agents_md;
|
||||
mod apps_instructions;
|
||||
mod environment;
|
||||
mod plugins_instructions;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_extension_api::PreviousWorldStateSection;
|
||||
@@ -16,7 +18,9 @@ use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
pub(crate) use agents_md::AgentsMdState;
|
||||
pub(crate) use apps_instructions::AppsInstructionsState;
|
||||
pub(crate) use environment::EnvironmentsState;
|
||||
pub(crate) use plugins_instructions::PluginsInstructionsState;
|
||||
|
||||
trait ErasedWorldStateSection: Send + Sync {
|
||||
fn snapshot(&self) -> Option<Value>;
|
||||
@@ -62,11 +66,11 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher(&self) -> bool {
|
||||
false
|
||||
S::has_retained_fragment_matcher()
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool {
|
||||
false
|
||||
fn matches_retained_fragment(&self, role: &str, text: &str) -> bool {
|
||||
S::matches_retained_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
@@ -183,6 +187,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether retained history must still contain this section's rendered fragment.
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recognizes this section's rendered fragment in retained model history.
|
||||
fn matches_retained_fragment(_role: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::AvailablePluginsInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
|
||||
/// Whether generic plugin usage guidance should be visible to the model.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct PluginsInstructionsState {
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl PluginsInstructionsState {
|
||||
pub(crate) fn new(available: bool) -> Self {
|
||||
Self { available }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for PluginsInstructionsState {
|
||||
const ID: &'static str = "plugins_instructions";
|
||||
type Snapshot = bool;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
|
||||
role == "developer" && AvailablePluginsInstructions::matches_text(text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
if !self.available
|
||||
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|
||||
|| matches!(previous, PreviousSectionState::Unknown)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Box::new(AvailablePluginsInstructions))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugins_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn render(
|
||||
state: PluginsInstructionsState,
|
||||
previous: PreviousSectionState<'_, bool>,
|
||||
) -> Vec<String> {
|
||||
state
|
||||
.render_diff(previous)
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.render())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_only_when_plugins_become_available() {
|
||||
let unavailable = PluginsInstructionsState::new(/*available*/ false);
|
||||
let available = PluginsInstructionsState::new(/*available*/ true);
|
||||
let false_snapshot = false;
|
||||
let true_snapshot = true;
|
||||
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Absent),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_guidance_is_not_injected_again() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
|
||||
let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
|
||||
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(/*previous*/ None, &[legacy])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_guidance_is_restored_only_when_missing_from_history() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(PluginsInstructionsState::new(/*available*/ true));
|
||||
let snapshot = world_state.snapshot();
|
||||
let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions);
|
||||
|
||||
assert_eq!(
|
||||
world_state.render_history_diff(Some(&snapshot), &[]).len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(Some(&snapshot), &[retained])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,10 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -513,6 +515,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
status: None,
|
||||
call_id: "tool-1".to_string(),
|
||||
name: "js_repl".to_string(),
|
||||
namespace: None,
|
||||
input: "view_image".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -582,6 +585,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
status: None,
|
||||
call_id: "tool-1".to_string(),
|
||||
name: "js_repl".to_string(),
|
||||
namespace: None,
|
||||
input: "view_image".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -1019,6 +1023,12 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
|
||||
user_input_text_msg("turn 1 user"),
|
||||
assistant_msg("turn 1 assistant"),
|
||||
developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."),
|
||||
developer_msg(&format!(
|
||||
"{APPS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_APPS_INSTRUCTIONS"
|
||||
)),
|
||||
developer_msg(&format!(
|
||||
"{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS"
|
||||
)),
|
||||
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
|
||||
developer_msg("<multi_agent_mode>ROLLED_BACK_MULTI_AGENT_MODE</multi_agent_mode>"),
|
||||
user_input_text_msg(
|
||||
@@ -1090,6 +1100,7 @@ fn remove_first_item_handles_custom_tool_pair() {
|
||||
status: None,
|
||||
call_id: "tool-1".to_string(),
|
||||
name: "my_tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -1378,6 +1389,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
|
||||
status: None,
|
||||
call_id: "tool-x".to_string(),
|
||||
name: "custom".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}];
|
||||
@@ -1393,6 +1405,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
|
||||
status: None,
|
||||
call_id: "tool-x".to_string(),
|
||||
name: "custom".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -1512,6 +1525,7 @@ fn normalize_mixed_inserts_and_removals() {
|
||||
status: None,
|
||||
call_id: "t1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -1556,6 +1570,7 @@ fn normalize_mixed_inserts_and_removals() {
|
||||
status: None,
|
||||
call_id: "t1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
@@ -1622,6 +1637,49 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() {
|
||||
let items = vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: Some("fc_existing".to_string()),
|
||||
name: "do_it".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-x".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: Some("msg_later".to_string()),
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "later turn".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
|
||||
let first = create_history_with_items(items.clone()).for_prompt(&default_input_modalities());
|
||||
let second = create_history_with_items(items).for_prompt(&default_input_modalities());
|
||||
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"repeated prompt projections should assign the same ID to the synthetic output"
|
||||
);
|
||||
let [
|
||||
ResponseItem::FunctionCall { .. },
|
||||
ResponseItem::FunctionCallOutput { id: Some(id), .. },
|
||||
ResponseItem::Message { .. },
|
||||
] = first.as_slice()
|
||||
else {
|
||||
panic!("expected the synthetic output between its call and the later message");
|
||||
};
|
||||
assert!(
|
||||
id.starts_with("fco_"),
|
||||
"the synthetic function call output should use the Responses API output ID prefix"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_adds_missing_output_for_tool_search_call() {
|
||||
let items = vec![ResponseItem::ToolSearchCall {
|
||||
@@ -1668,6 +1726,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() {
|
||||
status: None,
|
||||
call_id: "tool-x".to_string(),
|
||||
name: "custom".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}];
|
||||
@@ -1810,6 +1869,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() {
|
||||
status: None,
|
||||
call_id: "t1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
|
||||
@@ -4,12 +4,15 @@ use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::util::error_or_panic;
|
||||
use tracing::info;
|
||||
|
||||
const IMAGE_CONTENT_OMITTED_PLACEHOLDER: &str =
|
||||
"image content omitted because you do not support image input";
|
||||
// Changing this value would change model-visible IDs and invalidate prompt caches.
|
||||
const SYNTHETIC_OUTPUT_ID_NAMESPACE: Uuid = Uuid::from_u128(0x90d38d3e_6a5b_4d52_bfe2_2f1e634bfac4);
|
||||
|
||||
pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
let mut function_output_ids = HashSet::new();
|
||||
@@ -40,14 +43,14 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
ResponseItem::FunctionCall { call_id, .. }
|
||||
ResponseItem::FunctionCall { id, call_id, .. }
|
||||
if !function_output_ids.contains(call_id.as_str()) =>
|
||||
{
|
||||
info!("Function call output is missing for call id: {call_id}");
|
||||
missing_outputs_to_insert.push((
|
||||
idx,
|
||||
ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
id: synthetic_output_id("fco", id.as_deref()),
|
||||
call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
@@ -55,6 +58,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
));
|
||||
}
|
||||
ResponseItem::ToolSearchCall {
|
||||
id,
|
||||
call_id: Some(call_id),
|
||||
..
|
||||
} if !tool_search_output_ids.contains(call_id.as_str()) => {
|
||||
@@ -62,7 +66,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
missing_outputs_to_insert.push((
|
||||
idx,
|
||||
ResponseItem::ToolSearchOutput {
|
||||
id: None,
|
||||
id: synthetic_output_id("tso", id.as_deref()),
|
||||
call_id: Some(call_id.clone()),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
@@ -71,7 +75,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
},
|
||||
));
|
||||
}
|
||||
ResponseItem::CustomToolCall { call_id, .. }
|
||||
ResponseItem::CustomToolCall { id, call_id, .. }
|
||||
if !custom_tool_output_ids.contains(call_id.as_str()) =>
|
||||
{
|
||||
error_or_panic(format!(
|
||||
@@ -80,7 +84,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
missing_outputs_to_insert.push((
|
||||
idx,
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
id: None,
|
||||
id: synthetic_output_id("ctco", id.as_deref()),
|
||||
call_id: call_id.clone(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
@@ -90,6 +94,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
}
|
||||
// LocalShellCall is represented in upstream streams by a FunctionCallOutput
|
||||
ResponseItem::LocalShellCall {
|
||||
id,
|
||||
call_id: Some(call_id),
|
||||
..
|
||||
} if !function_output_ids.contains(call_id.as_str()) => {
|
||||
@@ -99,7 +104,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
missing_outputs_to_insert.push((
|
||||
idx,
|
||||
ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
id: synthetic_output_id("fco", id.as_deref()),
|
||||
call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
@@ -121,6 +126,21 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives a stable ID for a prompt-only output from its source call's item ID.
|
||||
///
|
||||
/// Prompt normalization can run repeatedly without persisting its synthetic
|
||||
/// outputs, so the namespace and name format must remain stable across retries
|
||||
/// and resumes to preserve prompt-cache reuse. Returning `None` when the source
|
||||
/// call has no ID preserves the legacy behavior for older history items.
|
||||
fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option<String> {
|
||||
let source_id = item_id.filter(|id| !id.is_empty())?;
|
||||
let name = format!("{prefix}:{source_id}");
|
||||
Some(format!(
|
||||
"{prefix}_{}",
|
||||
Uuid::new_v5(&SYNTHETIC_OUTPUT_ID_NAMESPACE, name.as_bytes())
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
|
||||
let function_call_ids: HashSet<String> = items
|
||||
.iter()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::context::ApprovalPromptContext;
|
||||
use crate::context::CollaborationModeInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::ModelSwitchInstructions;
|
||||
@@ -30,6 +31,7 @@ fn build_permissions_update_item(
|
||||
let prev = previous?;
|
||||
if prev.permission_profile() == next.permission_profile()
|
||||
&& prev.approval_policy == next.approval_policy.value()
|
||||
&& prev.model == next.model_info.slug
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -38,7 +40,13 @@ fn build_permissions_update_item(
|
||||
PermissionsInstructions::from_permission_profile(
|
||||
&next.permission_profile,
|
||||
next.approval_policy.value(),
|
||||
next.config.approvals_reviewer,
|
||||
ApprovalPromptContext::new(
|
||||
next.config.approvals_reviewer,
|
||||
next.model_info
|
||||
.model_messages
|
||||
.as_ref()
|
||||
.and_then(|messages| messages.approvals.as_ref()),
|
||||
),
|
||||
exec_policy,
|
||||
#[allow(deprecated)]
|
||||
&next.cwd,
|
||||
@@ -85,9 +93,6 @@ fn build_multi_agent_mode_update_item(
|
||||
}
|
||||
|
||||
match effective_multi_agent_mode {
|
||||
Some(MultiAgentMode::None) => {
|
||||
Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render())
|
||||
}
|
||||
Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()),
|
||||
None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => {
|
||||
Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render())
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// Coordinates user elicitations that pause tool-result delivery for a session.
|
||||
///
|
||||
/// Registrations are counted so concurrent elicitations keep the session paused until all of them
|
||||
/// finish. Consumers can subscribe to pause timeout progress or wait before returning an already
|
||||
/// captured result.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ElicitationService {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
state: Mutex<State>,
|
||||
paused: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
outstanding: i64,
|
||||
}
|
||||
|
||||
pub(crate) struct ElicitationRegistration {
|
||||
service: ElicitationService,
|
||||
}
|
||||
|
||||
impl ElicitationService {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (paused, _paused_rx) = watch::channel(false);
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
state: Mutex::new(State::default()),
|
||||
paused,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(&self) -> ElicitationRegistration {
|
||||
self.increment();
|
||||
ElicitationRegistration {
|
||||
service: self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(&self) {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let was_clear = state.outstanding == 0;
|
||||
assert_ne!(
|
||||
state.outstanding,
|
||||
i64::MAX,
|
||||
"outstanding elicitation count overflowed"
|
||||
);
|
||||
state.outstanding += 1;
|
||||
if was_clear {
|
||||
self.inner.paused.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> watch::Receiver<bool> {
|
||||
self.inner.paused.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_clear(&self) {
|
||||
let mut paused = self.subscribe();
|
||||
let _ = paused.wait_for(|paused| !*paused).await;
|
||||
}
|
||||
|
||||
fn decrement(&self) {
|
||||
let mut state = self
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert!(
|
||||
state.outstanding > 0,
|
||||
"elicitation registration count underflowed"
|
||||
);
|
||||
state.outstanding -= 1;
|
||||
if state.outstanding == 0 {
|
||||
self.inner.paused.send_replace(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ElicitationRegistration {
|
||||
fn drop(&mut self) {
|
||||
self.service.decrement();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "elicitation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_until_clear_waits_for_every_registration() {
|
||||
let service = ElicitationService::new();
|
||||
let first = service.register();
|
||||
let second = service.register();
|
||||
let waiting = tokio::spawn({
|
||||
let service = service.clone();
|
||||
async move { service.wait_until_clear().await }
|
||||
});
|
||||
|
||||
drop(first);
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!waiting.is_finished());
|
||||
|
||||
drop(second);
|
||||
waiting.await.expect("elicitation waiter should complete");
|
||||
}
|
||||
@@ -14,10 +14,12 @@ use codex_protocol::models::is_image_close_tag_text;
|
||||
use codex_protocol::models::is_image_open_tag_text;
|
||||
use codex_protocol::models::is_local_image_close_tag_text;
|
||||
use codex_protocol::models::is_local_image_open_tag_text;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG;
|
||||
use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -31,8 +33,10 @@ use crate::web_search::web_search_action_detail;
|
||||
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
|
||||
"<permissions instructions>",
|
||||
"<model_switch>",
|
||||
APPS_INSTRUCTIONS_OPEN_TAG,
|
||||
COLLABORATION_MODE_OPEN_TAG,
|
||||
MULTI_AGENT_MODE_OPEN_TAG,
|
||||
PLUGINS_INSTRUCTIONS_OPEN_TAG,
|
||||
REALTIME_CONVERSATION_OPEN_TAG,
|
||||
SKILLS_INSTRUCTIONS_OPEN_TAG,
|
||||
"<personality_spec>",
|
||||
|
||||
@@ -554,12 +554,19 @@ pub fn format_exec_policy_error_with_source(error: &ExecPolicyError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_exec_policy_with_warning(
|
||||
pub(crate) async fn load_exec_policy_with_warning(
|
||||
config_stack: &ConfigLayerStack,
|
||||
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
|
||||
match load_exec_policy(config_stack).await {
|
||||
Ok(policy) => Ok((policy, None)),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
|
||||
let policy = config_stack
|
||||
.requirements()
|
||||
.exec_policy
|
||||
.as_deref()
|
||||
.map_or_else(Policy::empty, |policy| policy.as_ref().clone());
|
||||
Ok((policy, Some(err)))
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +374,42 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_custom_rules_preserve_requirements_exec_policy() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
|
||||
fs::create_dir_all(&policy_dir)?;
|
||||
fs::write(policy_dir.join("broken.rules"), "prefix_rule(")?;
|
||||
|
||||
let mut requirements_exec_policy = Policy::empty();
|
||||
requirements_exec_policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden)?;
|
||||
let requirements = ConfigRequirements {
|
||||
exec_policy: Some(Sourced::new(
|
||||
RequirementsExecPolicy::new(requirements_exec_policy),
|
||||
RequirementSource::Unknown,
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
};
|
||||
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
TomlValue::Table(Default::default()),
|
||||
);
|
||||
let config_stack =
|
||||
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
|
||||
|
||||
let (policy, warning) = load_exec_policy_with_warning(&config_stack).await?;
|
||||
|
||||
assert!(matches!(warning, Some(ExecPolicyError::ParsePolicy { .. })));
|
||||
assert_eq!(
|
||||
policy
|
||||
.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
|
||||
.decision,
|
||||
Decision::Forbidden
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
|
||||
@@ -19,6 +19,7 @@ pub use responses_metadata::CodexResponsesMetadata;
|
||||
pub use session::SteerInputError;
|
||||
pub use turn_metadata::detached_memory_responses_metadata;
|
||||
mod codex_thread;
|
||||
mod compact_model_fallback;
|
||||
mod compact_remote;
|
||||
mod compact_remote_v2;
|
||||
mod compact_token_budget;
|
||||
@@ -31,6 +32,7 @@ pub use codex_thread::TryStartTurnIfIdleError;
|
||||
pub use codex_thread::TryStartTurnIfIdleRejectionReason;
|
||||
pub use session::turn_context::TurnContext;
|
||||
mod agent;
|
||||
mod agent_communication;
|
||||
mod attestation;
|
||||
mod codex_delegate;
|
||||
mod command_canonicalization;
|
||||
@@ -39,6 +41,7 @@ pub mod connectors;
|
||||
pub mod context;
|
||||
mod context_manager;
|
||||
mod current_time;
|
||||
mod elicitation;
|
||||
mod environment_selection;
|
||||
pub mod exec;
|
||||
pub mod exec_env;
|
||||
@@ -145,6 +148,7 @@ pub(crate) mod state_db_bridge;
|
||||
pub use state_db_bridge::StateDbHandle;
|
||||
pub use state_db_bridge::init_state_db;
|
||||
mod thread_rollout_truncation;
|
||||
pub use thread_rollout_truncation::truncate_rollout_after_turn_id;
|
||||
mod tools;
|
||||
pub(crate) mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
|
||||
+34
-16
@@ -25,6 +25,12 @@ use codex_plugin::AppConnectorId;
|
||||
|
||||
const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps";
|
||||
|
||||
/// MCP configuration and capability availability derived from the same inputs.
|
||||
pub(crate) struct McpRuntimeProjection {
|
||||
pub(crate) config: McpConfig,
|
||||
pub(crate) plugins_available: bool,
|
||||
}
|
||||
|
||||
enum OrderedMcpOverlay {
|
||||
Set {
|
||||
contributor_id: &'static str,
|
||||
@@ -75,6 +81,7 @@ impl McpManager {
|
||||
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
|
||||
self.runtime_config_with_context(McpServerContributionContext::global(config))
|
||||
.await
|
||||
.config
|
||||
}
|
||||
|
||||
pub(crate) async fn runtime_config_for_step(
|
||||
@@ -83,7 +90,7 @@ impl McpManager {
|
||||
thread_init: &ExtensionDataInit,
|
||||
thread_store: &ExtensionData,
|
||||
available_environment_ids: &[String],
|
||||
) -> McpConfig {
|
||||
) -> McpRuntimeProjection {
|
||||
self.runtime_config_with_context(McpServerContributionContext::for_step(
|
||||
config,
|
||||
thread_init,
|
||||
@@ -96,8 +103,9 @@ impl McpManager {
|
||||
async fn runtime_config_with_context(
|
||||
&self,
|
||||
context: McpServerContributionContext<'_, Config>,
|
||||
) -> McpConfig {
|
||||
) -> McpRuntimeProjection {
|
||||
let config = context.config();
|
||||
let mut selected_plugin_available = false;
|
||||
let mut selected_plugin_connector_sources = Vec::new();
|
||||
let mut selected_plugin_registrations = Vec::new();
|
||||
let mut overlays = Vec::new();
|
||||
@@ -129,17 +137,22 @@ impl McpManager {
|
||||
*config,
|
||||
),
|
||||
),
|
||||
McpServerContribution::SelectedPluginConnectors {
|
||||
McpServerContribution::SelectedPluginPackage {
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
connector_ids,
|
||||
} => selected_plugin_connector_sources.push(
|
||||
PluginConnectorSource::from_connector_ids(
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
connector_ids.into_iter().map(AppConnectorId),
|
||||
),
|
||||
),
|
||||
} => {
|
||||
selected_plugin_available = true;
|
||||
if !connector_ids.is_empty() {
|
||||
selected_plugin_connector_sources.push(
|
||||
PluginConnectorSource::from_connector_ids(
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
connector_ids.into_iter().map(AppConnectorId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
McpServerContribution::Remove { name } => {
|
||||
overlays.push(OrderedMcpOverlay::Remove {
|
||||
contributor_id: contributor.id(),
|
||||
@@ -152,12 +165,14 @@ impl McpManager {
|
||||
}
|
||||
}
|
||||
|
||||
let mut mcp_config = config
|
||||
.to_mcp_config_with_plugin_registrations(
|
||||
self.plugins_manager.as_ref(),
|
||||
selected_plugin_registrations,
|
||||
)
|
||||
let loaded_plugins = self
|
||||
.plugins_manager
|
||||
.plugins_for_config(&config.plugins_config_input())
|
||||
.await;
|
||||
let plugins_available =
|
||||
selected_plugin_available || !loaded_plugins.capability_summaries().is_empty();
|
||||
let mut mcp_config = config
|
||||
.to_mcp_config_with_loaded_plugins(&loaded_plugins, selected_plugin_registrations);
|
||||
let mut catalog = mcp_config.mcp_server_catalog.to_builder();
|
||||
if mcp_config.apps_enabled {
|
||||
catalog.register(McpServerRegistration::from_compatibility(
|
||||
@@ -211,7 +226,10 @@ impl McpManager {
|
||||
.merged_with(&ConnectorSnapshot::from_plugin_sources(
|
||||
selected_plugin_connector_sources,
|
||||
));
|
||||
mcp_config
|
||||
McpRuntimeProjection {
|
||||
config: mcp_config,
|
||||
plugins_available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns config- and plugin-backed servers without runtime contributions.
|
||||
|
||||
@@ -1223,8 +1223,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
}
|
||||
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
let approval_required = requires_mcp_tool_approval(annotations);
|
||||
if !approval_required && approval_mode != AppToolApproval::Prompt {
|
||||
if !requires_mcp_tool_approval_for_mode(annotations, approval_mode) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1927,12 +1926,13 @@ fn normalize_approval_decision_for_mode(
|
||||
decision: McpToolApprovalDecision,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> McpToolApprovalDecision {
|
||||
if approval_mode == AppToolApproval::Prompt
|
||||
&& matches!(
|
||||
decision,
|
||||
McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember
|
||||
)
|
||||
{
|
||||
if matches!(
|
||||
approval_mode,
|
||||
AppToolApproval::Prompt | AppToolApproval::Writes
|
||||
) && matches!(
|
||||
decision,
|
||||
McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember
|
||||
) {
|
||||
McpToolApprovalDecision::Accept
|
||||
} else {
|
||||
decision
|
||||
@@ -2179,6 +2179,20 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn requires_mcp_tool_approval_for_mode(
|
||||
annotations: Option<&ToolAnnotations>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> bool {
|
||||
match approval_mode {
|
||||
AppToolApproval::Auto => requires_mcp_tool_approval(annotations),
|
||||
AppToolApproval::Prompt => true,
|
||||
AppToolApproval::Writes => !annotations
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
.unwrap_or(false),
|
||||
AppToolApproval::Approve => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_mcp_tool_call_skip(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
|
||||
@@ -398,23 +398,47 @@ fn approval_not_required_when_read_only_and_other_hints_are_absent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_mode_does_not_allow_persistent_remember() {
|
||||
fn writes_mode_requires_approval_for_non_read_only_tools() {
|
||||
let annotations = annotations(Some(false), Some(false), Some(false));
|
||||
assert_eq!(
|
||||
normalize_approval_decision_for_mode(
|
||||
McpToolApprovalDecision::AcceptForSession,
|
||||
AppToolApproval::Prompt,
|
||||
),
|
||||
McpToolApprovalDecision::Accept
|
||||
requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_approval_decision_for_mode(
|
||||
McpToolApprovalDecision::AcceptAndRemember,
|
||||
AppToolApproval::Prompt,
|
||||
),
|
||||
McpToolApprovalDecision::Accept
|
||||
requires_mcp_tool_approval_for_mode(/*annotations*/ None, AppToolApproval::Writes),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_mode_does_not_require_approval_for_read_only_tools() {
|
||||
let annotations = annotations(Some(true), Some(true), Some(true));
|
||||
assert_eq!(
|
||||
requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompting_modes_do_not_allow_persistent_remember() {
|
||||
for approval_mode in [AppToolApproval::Prompt, AppToolApproval::Writes] {
|
||||
assert_eq!(
|
||||
normalize_approval_decision_for_mode(
|
||||
McpToolApprovalDecision::AcceptForSession,
|
||||
approval_mode,
|
||||
),
|
||||
McpToolApprovalDecision::Accept
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_approval_decision_for_mode(
|
||||
McpToolApprovalDecision::AcceptAndRemember,
|
||||
approval_mode,
|
||||
),
|
||||
McpToolApprovalDecision::Accept
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_call_span_records_expected_fields() {
|
||||
let buffer: &'static std::sync::Mutex<Vec<u8>> =
|
||||
@@ -1439,6 +1463,7 @@ async fn host_owned_codex_apps_manager(
|
||||
codex_mcp::ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -1447,7 +1472,11 @@ async fn host_owned_codex_apps_manager(
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() {
|
||||
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.disable(Feature::AuthElicitation);
|
||||
let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features);
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
@@ -1560,16 +1589,10 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() {
|
||||
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
let manager = host_owned_codex_apps_manager(&session, &turn_context).await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::AuthElicitation);
|
||||
{
|
||||
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
|
||||
Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features);
|
||||
}
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ fn denied_network_policy_message_requires_deny_decision() {
|
||||
method: Some("GET".to_string()),
|
||||
mode: None,
|
||||
protocol: "http".to_string(),
|
||||
execution_id: None,
|
||||
decision: Some("ask".to_string()),
|
||||
source: Some("decider".to_string()),
|
||||
port: Some(80),
|
||||
@@ -178,6 +179,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() {
|
||||
method: Some("GET".to_string()),
|
||||
mode: None,
|
||||
protocol: "http".to_string(),
|
||||
execution_id: None,
|
||||
decision: Some("deny".to_string()),
|
||||
source: Some("baseline_policy".to_string()),
|
||||
port: Some(80),
|
||||
|
||||
@@ -2,9 +2,8 @@ use crate::config::find_codex_home;
|
||||
use crate::config::is_builtin_permission_profile_name;
|
||||
use crate::config::reject_unknown_builtin_permission_profile;
|
||||
use crate::config::resolve_permission_profile;
|
||||
use crate::exec_policy::ExecPolicyError;
|
||||
use crate::exec_policy::format_exec_policy_error_with_source;
|
||||
use crate::exec_policy::load_exec_policy;
|
||||
use crate::exec_policy::load_exec_policy_with_warning;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
@@ -64,13 +63,15 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
|
||||
.await
|
||||
.context("failed to load Codex config")?;
|
||||
|
||||
let (exec_policy, warning) = match load_exec_policy(&config_layer_stack).await {
|
||||
Ok(policy) => (policy, None),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
|
||||
(codex_execpolicy::Policy::empty(), Some(err))
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let layer_mtimes = collect_layer_mtimes(&config_layer_stack);
|
||||
let state = build_config_state_from_layers(&config_layer_stack).await?;
|
||||
Ok((state, layer_mtimes))
|
||||
}
|
||||
|
||||
async fn build_config_state_from_layers(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> Result<ConfigState> {
|
||||
let (exec_policy, warning) = load_exec_policy_with_warning(config_layer_stack).await?;
|
||||
if let Some(err) = warning.as_ref() {
|
||||
tracing::warn!(
|
||||
"failed to parse execpolicy while building network proxy state: {}",
|
||||
@@ -78,12 +79,10 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
|
||||
);
|
||||
}
|
||||
|
||||
let config = config_from_layers(&config_layer_stack, &exec_policy)?;
|
||||
let config = config_from_layers(config_layer_stack, &exec_policy)?;
|
||||
|
||||
let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?;
|
||||
let layer_mtimes = collect_layer_mtimes(&config_layer_stack);
|
||||
let state = build_config_state(config, constraints)?;
|
||||
Ok((state, layer_mtimes))
|
||||
let constraints = enforce_trusted_constraints(config_layer_stack, &config)?;
|
||||
build_config_state(config, constraints)
|
||||
}
|
||||
|
||||
fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec<LayerMtime> {
|
||||
|
||||
@@ -5,6 +5,9 @@ use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::RequirementSource;
|
||||
use codex_config::RequirementsExecPolicy;
|
||||
use codex_config::Sourced;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
|
||||
use codex_execpolicy::Decision;
|
||||
@@ -12,6 +15,8 @@ use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn higher_precedence_profile_network_overlays_domain_entries() {
|
||||
@@ -225,6 +230,51 @@ fn execpolicy_network_rules_overlay_network_lists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_custom_rules_preserve_managed_denied_domain() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
let policy_dir = temp_dir.path().join("rules");
|
||||
fs::create_dir_all(&policy_dir).expect("create policy dir");
|
||||
fs::write(policy_dir.join("broken.rules"), "prefix_rule(").expect("write malformed policy");
|
||||
|
||||
let mut requirements_exec_policy = Policy::empty();
|
||||
requirements_exec_policy
|
||||
.add_network_rule(
|
||||
"blocked.example.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Forbidden,
|
||||
/*justification*/ None,
|
||||
)
|
||||
.expect("managed network rule should be valid");
|
||||
let requirements = ConfigRequirements {
|
||||
exec_policy: Some(Sourced::new(
|
||||
RequirementsExecPolicy::new(requirements_exec_policy),
|
||||
RequirementSource::Unknown,
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
};
|
||||
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())
|
||||
.expect("dot codex folder should be absolute");
|
||||
let layers = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
toml::Value::Table(Default::default()),
|
||||
)],
|
||||
requirements,
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("layer stack should be valid");
|
||||
|
||||
let state = build_config_state_from_layers(&layers)
|
||||
.await
|
||||
.expect("proxy state should tolerate malformed custom rules");
|
||||
|
||||
assert_eq!(
|
||||
state.config.network.denied_domains(),
|
||||
Some(vec!["blocked.example.com".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
|
||||
dynamic_tools: None,
|
||||
selected_capability_roots: Vec::new(),
|
||||
memory_mode: None,
|
||||
history_mode: Default::default(),
|
||||
multi_agent_version: None,
|
||||
context_window: None,
|
||||
},
|
||||
|
||||
@@ -69,7 +69,6 @@ async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_pl
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -306,7 +305,6 @@ remote_plugin = true
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
|
||||
[tool_suggest]
|
||||
disabled_tools = [
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::plugins::PluginCapabilitySummary;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option<String> {
|
||||
AvailablePluginsInstructions::from_plugins(plugins).map(|instructions| instructions.render())
|
||||
(!plugins.is_empty()).then(|| AvailablePluginsInstructions.render())
|
||||
}
|
||||
|
||||
pub(crate) fn render_explicit_plugin_instructions(
|
||||
|
||||
@@ -55,6 +55,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version: "test".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
history_mode: Default::default(),
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_protocol::protocol::RealtimeConversationSdpEvent;
|
||||
use codex_protocol::protocol::RealtimeConversationStartedEvent;
|
||||
use codex_protocol::protocol::RealtimeHandoffRequested;
|
||||
use codex_protocol::protocol::RealtimeOutputModality;
|
||||
use codex_protocol::protocol::RealtimeTranscriptEntry;
|
||||
use codex_protocol::protocol::RealtimeVoice;
|
||||
use codex_protocol::protocol::RealtimeVoicesList;
|
||||
use http::HeaderMap;
|
||||
@@ -59,6 +60,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
@@ -80,6 +82,7 @@ const REALTIME_V2_STEER_ACKNOWLEDGEMENT: &str =
|
||||
"This was sent to steer the previous background agent task.";
|
||||
const REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX: &str =
|
||||
"Conversation already has an active response in progress:";
|
||||
const REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION: &str = "The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RealtimeConversationEnd {
|
||||
@@ -89,7 +92,7 @@ enum RealtimeConversationEnd {
|
||||
}
|
||||
|
||||
enum RealtimeFanoutTaskStop {
|
||||
Abort,
|
||||
Await,
|
||||
Detach,
|
||||
}
|
||||
|
||||
@@ -203,6 +206,9 @@ struct RealtimeInputTask {
|
||||
handoff_state: RealtimeHandoffState,
|
||||
session_kind: RealtimeSessionKind,
|
||||
event_parser: RealtimeEventParser,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
transcript_tail_tx: Sender<String>,
|
||||
stop_token: CancellationToken,
|
||||
}
|
||||
|
||||
struct RealtimeInputChannels {
|
||||
@@ -242,12 +248,14 @@ struct ConversationState {
|
||||
input_task: JoinHandle<()>,
|
||||
fanout_task: Option<JoinHandle<()>>,
|
||||
realtime_active: Arc<AtomicBool>,
|
||||
stop_token: CancellationToken,
|
||||
}
|
||||
|
||||
struct RealtimeStart {
|
||||
api_provider: ApiProvider,
|
||||
extra_headers: Option<HeaderMap>,
|
||||
client_managed_handoffs: bool,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
codex_responses_as_items: bool,
|
||||
codex_response_item_prefix: Option<String>,
|
||||
codex_response_handoff_prefix: Option<String>,
|
||||
@@ -260,6 +268,7 @@ struct RealtimeStart {
|
||||
struct RealtimeStartOutput {
|
||||
realtime_active: Arc<AtomicBool>,
|
||||
events_rx: Receiver<RealtimeEvent>,
|
||||
transcript_tail_rx: Receiver<String>,
|
||||
sdp: Option<String>,
|
||||
}
|
||||
|
||||
@@ -294,7 +303,7 @@ impl RealtimeConversationManager {
|
||||
guard.take()
|
||||
};
|
||||
if let Some(state) = previous_state {
|
||||
stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await;
|
||||
stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
|
||||
}
|
||||
|
||||
self.start_inner(start).await
|
||||
@@ -305,6 +314,7 @@ impl RealtimeConversationManager {
|
||||
api_provider,
|
||||
extra_headers,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
codex_response_item_prefix,
|
||||
codex_response_handoff_prefix,
|
||||
@@ -327,8 +337,10 @@ impl RealtimeConversationManager {
|
||||
async_channel::bounded::<RealtimeOutbound>(HANDOFF_OUT_QUEUE_CAPACITY);
|
||||
let (events_tx, events_rx) =
|
||||
async_channel::bounded::<RealtimeEvent>(OUTPUT_EVENTS_QUEUE_CAPACITY);
|
||||
let (transcript_tail_tx, transcript_tail_rx) = async_channel::bounded::<String>(1);
|
||||
|
||||
let realtime_active = Arc::new(AtomicBool::new(true));
|
||||
let stop_token = CancellationToken::new();
|
||||
let handoff = RealtimeHandoffState::new(
|
||||
handoff_output_tx,
|
||||
client_managed_handoffs,
|
||||
@@ -364,6 +376,9 @@ impl RealtimeConversationManager {
|
||||
session_kind,
|
||||
event_parser,
|
||||
realtime_active: Arc::clone(&realtime_active),
|
||||
flush_transcript_tail_on_session_end,
|
||||
transcript_tail_tx,
|
||||
stop_token: stop_token.clone(),
|
||||
});
|
||||
(task, Some(call.sdp))
|
||||
} else {
|
||||
@@ -385,6 +400,9 @@ impl RealtimeConversationManager {
|
||||
handoff_state: handoff.clone(),
|
||||
session_kind,
|
||||
event_parser,
|
||||
flush_transcript_tail_on_session_end,
|
||||
transcript_tail_tx,
|
||||
stop_token: stop_token.clone(),
|
||||
});
|
||||
(task, None)
|
||||
};
|
||||
@@ -398,10 +416,12 @@ impl RealtimeConversationManager {
|
||||
input_task: task,
|
||||
fanout_task: None,
|
||||
realtime_active: Arc::clone(&realtime_active),
|
||||
stop_token,
|
||||
});
|
||||
Ok(RealtimeStartOutput {
|
||||
realtime_active,
|
||||
events_rx,
|
||||
transcript_tail_rx,
|
||||
sdp,
|
||||
})
|
||||
}
|
||||
@@ -652,7 +672,7 @@ impl RealtimeConversationManager {
|
||||
};
|
||||
|
||||
if let Some(state) = state {
|
||||
stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await;
|
||||
stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -663,13 +683,12 @@ async fn stop_conversation_state(
|
||||
fanout_task_stop: RealtimeFanoutTaskStop,
|
||||
) {
|
||||
state.realtime_active.store(false, Ordering::Relaxed);
|
||||
state.input_task.abort();
|
||||
state.stop_token.cancel();
|
||||
let _ = state.input_task.await;
|
||||
|
||||
if let Some(fanout_task) = state.fanout_task.take() {
|
||||
match fanout_task_stop {
|
||||
RealtimeFanoutTaskStop::Abort => {
|
||||
fanout_task.abort();
|
||||
RealtimeFanoutTaskStop::Await => {
|
||||
let _ = fanout_task.await;
|
||||
}
|
||||
RealtimeFanoutTaskStop::Detach => {}
|
||||
@@ -716,6 +735,7 @@ struct PreparedRealtimeConversationStart {
|
||||
api_provider: ApiProvider,
|
||||
extra_headers: Option<HeaderMap>,
|
||||
client_managed_handoffs: bool,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
codex_responses_as_items: bool,
|
||||
codex_response_item_prefix: Option<String>,
|
||||
codex_response_handoff_prefix: Option<String>,
|
||||
@@ -800,6 +820,7 @@ async fn prepare_realtime_start(
|
||||
api_provider,
|
||||
extra_headers,
|
||||
client_managed_handoffs: params.client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items: params.codex_responses_as_items,
|
||||
codex_response_item_prefix: params.codex_response_item_prefix,
|
||||
codex_response_handoff_prefix: params.codex_response_handoff_prefix,
|
||||
@@ -966,6 +987,7 @@ async fn handle_start_inner(
|
||||
api_provider,
|
||||
extra_headers,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
codex_response_item_prefix,
|
||||
codex_response_handoff_prefix,
|
||||
@@ -984,6 +1006,7 @@ async fn handle_start_inner(
|
||||
api_provider,
|
||||
extra_headers,
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end,
|
||||
codex_responses_as_items,
|
||||
codex_response_item_prefix,
|
||||
codex_response_handoff_prefix,
|
||||
@@ -1008,6 +1031,7 @@ async fn handle_start_inner(
|
||||
let RealtimeStartOutput {
|
||||
realtime_active,
|
||||
events_rx,
|
||||
transcript_tail_rx,
|
||||
sdp,
|
||||
} = start_output;
|
||||
if let Some(sdp) = sdp {
|
||||
@@ -1027,10 +1051,8 @@ async fn handle_start_inner(
|
||||
msg,
|
||||
};
|
||||
let mut end = RealtimeConversationEnd::TransportClosed;
|
||||
// Drain already-parsed events so a queued handoff is routed before the final tail.
|
||||
while let Ok(event) = events_rx.recv().await {
|
||||
if !fanout_realtime_active.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match &event {
|
||||
RealtimeEvent::AudioOut(_) => {}
|
||||
_ => {
|
||||
@@ -1054,9 +1076,6 @@ async fn handle_start_inner(
|
||||
let sess_for_routed_text = Arc::clone(&sess_clone);
|
||||
sess_for_routed_text.route_realtime_text_input(text).await;
|
||||
}
|
||||
if !fanout_realtime_active.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
sess_clone
|
||||
.send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
|
||||
RealtimeConversationRealtimeEvent {
|
||||
@@ -1065,6 +1084,9 @@ async fn handle_start_inner(
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
if let Ok(text) = transcript_tail_rx.recv().await {
|
||||
sess_clone.route_realtime_text_input(text).await;
|
||||
}
|
||||
if fanout_realtime_active.swap(false, Ordering::Relaxed) {
|
||||
match end {
|
||||
RealtimeConversationEnd::TransportClosed => {
|
||||
@@ -1103,8 +1125,11 @@ pub(crate) async fn handle_audio(
|
||||
}
|
||||
|
||||
fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option<String> {
|
||||
let active_transcript = handoff
|
||||
.active_transcript
|
||||
realtime_transcript_delta(&handoff.active_transcript)
|
||||
}
|
||||
|
||||
fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option<String> {
|
||||
let active_transcript = active_transcript
|
||||
.iter()
|
||||
.map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1254,6 +1279,9 @@ struct RealtimeWebrtcSidebandInputTask {
|
||||
session_kind: RealtimeSessionKind,
|
||||
event_parser: RealtimeEventParser,
|
||||
realtime_active: Arc<AtomicBool>,
|
||||
flush_transcript_tail_on_session_end: bool,
|
||||
transcript_tail_tx: Sender<String>,
|
||||
stop_token: CancellationToken,
|
||||
}
|
||||
|
||||
fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> JoinHandle<()> {
|
||||
@@ -1268,6 +1296,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
|
||||
session_kind,
|
||||
event_parser,
|
||||
realtime_active,
|
||||
flush_transcript_tail_on_session_end,
|
||||
transcript_tail_tx,
|
||||
stop_token,
|
||||
} = input;
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -1275,15 +1306,15 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
|
||||
return;
|
||||
}
|
||||
|
||||
let connection = match client
|
||||
.connect_webrtc_sideband(
|
||||
let connection = match tokio::select! {
|
||||
connection = client.connect_webrtc_sideband(
|
||||
session_config,
|
||||
&call_id,
|
||||
sideband_headers,
|
||||
default_headers(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
) => connection,
|
||||
_ = stop_token.cancelled() => return,
|
||||
} {
|
||||
Ok(connection) => connection,
|
||||
Err(err) => {
|
||||
if realtime_active.load(Ordering::Relaxed) {
|
||||
@@ -1311,6 +1342,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J
|
||||
handoff_state,
|
||||
session_kind,
|
||||
event_parser,
|
||||
flush_transcript_tail_on_session_end,
|
||||
transcript_tail_tx,
|
||||
stop_token,
|
||||
})
|
||||
.await;
|
||||
})
|
||||
@@ -1327,6 +1361,9 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
|
||||
handoff_state,
|
||||
session_kind,
|
||||
event_parser,
|
||||
flush_transcript_tail_on_session_end,
|
||||
transcript_tail_tx,
|
||||
stop_token,
|
||||
} = input;
|
||||
|
||||
let mut output_audio_state: Option<OutputAudioState> = None;
|
||||
@@ -1334,6 +1371,7 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
|
||||
|
||||
loop {
|
||||
let result = tokio::select! {
|
||||
_ = stop_token.cancelled() => break,
|
||||
// Text input that should be sent into realtime.
|
||||
text = text_rx.recv() => {
|
||||
handle_text_input(
|
||||
@@ -1378,6 +1416,18 @@ async fn run_realtime_input_task(input: RealtimeInputTask) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if flush_transcript_tail_on_session_end
|
||||
&& let Some(transcript_delta) =
|
||||
realtime_transcript_delta(&events.take_transcript_tail().await)
|
||||
{
|
||||
let _ = transcript_tail_tx
|
||||
.send(wrap_realtime_delegation_input(
|
||||
REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION,
|
||||
Some(&transcript_delta),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_text_input(
|
||||
|
||||
@@ -93,6 +93,22 @@ impl CompactionTurnMetadata {
|
||||
strategy: CompactionStrategy::Memento,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trigger(self) -> CompactionTrigger {
|
||||
self.trigger
|
||||
}
|
||||
|
||||
pub(crate) fn reason(self) -> CompactionReason {
|
||||
self.reason
|
||||
}
|
||||
|
||||
pub(crate) fn implementation(self) -> CompactionImplementation {
|
||||
self.implementation
|
||||
}
|
||||
|
||||
pub(crate) fn phase(self) -> CompactionPhase {
|
||||
self.phase
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -285,6 +285,7 @@ pub async fn inter_agent_communication(
|
||||
sess.input_queue
|
||||
.enqueue_mailbox_communication(communication)
|
||||
.await;
|
||||
crate::agent_communication::emit_agent_communication_receive(&sub_id);
|
||||
if trigger_turn {
|
||||
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
||||
.await;
|
||||
@@ -588,8 +589,8 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
||||
if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await {
|
||||
startup_prewarm.abort().await;
|
||||
}
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
let _ = sess.conversation.shutdown().await;
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
sess.services
|
||||
.unified_exec_manager
|
||||
.terminate_all_processes()
|
||||
@@ -850,6 +851,11 @@ pub(super) async fn submission_loop(
|
||||
if !shutdown_received {
|
||||
shutdown_session_runtime(&sess).await;
|
||||
emit_thread_stop_lifecycle(sess.as_ref()).await;
|
||||
if let Some(live_thread) = sess.live_thread()
|
||||
&& let Err(err) = live_thread.shutdown().await
|
||||
{
|
||||
warn!("failed to shutdown thread persistence after submission channel closed: {err}");
|
||||
}
|
||||
}
|
||||
debug!("Agent loop exited");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::mcp::McpRuntimeProjection;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_mcp::ElicitationReviewRequest;
|
||||
use codex_mcp::ElicitationReviewer;
|
||||
@@ -92,6 +93,7 @@ impl Session {
|
||||
&available_environment_ids,
|
||||
)
|
||||
.await
|
||||
.config
|
||||
}
|
||||
|
||||
pub(crate) async fn runtime_mcp_servers(
|
||||
@@ -123,7 +125,7 @@ impl Session {
|
||||
if current.available_environment_ids() == available_environment_ids {
|
||||
return current;
|
||||
}
|
||||
let mcp_config = self
|
||||
let mcp_projection = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
@@ -133,6 +135,7 @@ impl Session {
|
||||
&available_environment_ids,
|
||||
)
|
||||
.await;
|
||||
let mcp_config = &mcp_projection.config;
|
||||
let changed_environment_is_used_by_mcp = mcp_config
|
||||
.mcp_server_catalog
|
||||
.configured_servers()
|
||||
@@ -156,6 +159,7 @@ impl Session {
|
||||
// replacing the live manager and restarting its processes.
|
||||
let runtime = Arc::new(McpRuntimeSnapshot::new(
|
||||
Arc::new(current.config().clone()),
|
||||
mcp_projection.plugins_available,
|
||||
current.manager_arc(),
|
||||
current.runtime_context().clone(),
|
||||
available_environment_ids,
|
||||
@@ -165,7 +169,7 @@ impl Session {
|
||||
}
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
mcp_config,
|
||||
mcp_projection,
|
||||
environments,
|
||||
&available_environment_ids,
|
||||
Some(self.mcp_elicitation_reviewer()),
|
||||
@@ -191,6 +195,11 @@ impl Session {
|
||||
Arc::new(GuardianMcpElicitationReviewer::new(self))
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle {
|
||||
let elicitations = self.services.elicitations.clone();
|
||||
codex_mcp::ElicitationLifecycle::new(move || elicitations.register())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
@@ -218,6 +227,7 @@ impl Session {
|
||||
};
|
||||
}
|
||||
|
||||
let _elicitation = self.services.elicitations.register();
|
||||
let (tx_response, rx_response) = oneshot::channel();
|
||||
let prev_entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
@@ -309,12 +319,16 @@ impl Session {
|
||||
async fn refresh_mcp_servers_inner(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
mcp_config: McpConfig,
|
||||
mcp_projection: McpRuntimeProjection,
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
available_environment_ids: &[String],
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
) -> Arc<McpRuntimeSnapshot> {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let McpRuntimeProjection {
|
||||
config: mcp_config,
|
||||
plugins_available,
|
||||
} = mcp_projection;
|
||||
let mcp_config = Arc::new(mcp_config);
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
|
||||
let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
@@ -369,6 +383,7 @@ impl Session {
|
||||
tool_plugin_provenance,
|
||||
auth.as_ref(),
|
||||
elicitation_reviewer,
|
||||
Some(self.mcp_elicitation_lifecycle()),
|
||||
current_runtime.manager().elicitation_router(),
|
||||
)
|
||||
.await;
|
||||
@@ -376,6 +391,7 @@ impl Session {
|
||||
.set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny());
|
||||
self.services.publish_mcp_runtime(
|
||||
mcp_config,
|
||||
plugins_available,
|
||||
mcp_runtime_context,
|
||||
available_environment_ids.to_vec(),
|
||||
refreshed_manager,
|
||||
@@ -448,7 +464,7 @@ impl Session {
|
||||
.latest_mcp_runtime()
|
||||
.available_environment_ids()
|
||||
.to_vec();
|
||||
let mut mcp_config = self
|
||||
let mut mcp_projection = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
@@ -458,12 +474,13 @@ impl Session {
|
||||
&available_environment_ids,
|
||||
)
|
||||
.await;
|
||||
mcp_config.mcp_server_catalog = mcp_config
|
||||
mcp_projection.config.mcp_server_catalog = mcp_projection
|
||||
.config
|
||||
.mcp_server_catalog
|
||||
.with_materialized_servers(mcp_servers);
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
mcp_config,
|
||||
mcp_projection,
|
||||
&turn_context.environments,
|
||||
&available_environment_ids,
|
||||
elicitation_reviewer,
|
||||
@@ -515,7 +532,7 @@ impl Session {
|
||||
.latest_mcp_runtime()
|
||||
.available_environment_ids()
|
||||
.to_vec();
|
||||
let mcp_config = self
|
||||
let mcp_projection = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.runtime_config_for_step(
|
||||
@@ -527,7 +544,7 @@ impl Session {
|
||||
.await;
|
||||
self.refresh_mcp_servers_inner(
|
||||
turn_context,
|
||||
mcp_config,
|
||||
mcp_projection,
|
||||
&turn_context.environments,
|
||||
&available_environment_ids,
|
||||
elicitation_reviewer,
|
||||
|
||||
@@ -5,9 +5,10 @@ use codex_mcp::McpConfig;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpRuntimeContext;
|
||||
|
||||
/// MCP config, exact environment bindings, and manager used by one model request.
|
||||
/// MCP config, plugin availability, exact environment bindings, and manager for one request.
|
||||
pub struct McpRuntimeSnapshot {
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
manager: Arc<McpConnectionManager>,
|
||||
runtime_context: McpRuntimeContext,
|
||||
available_environment_ids: Vec<String>,
|
||||
@@ -16,12 +17,14 @@ pub struct McpRuntimeSnapshot {
|
||||
impl McpRuntimeSnapshot {
|
||||
pub(crate) fn new(
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
manager: Arc<McpConnectionManager>,
|
||||
runtime_context: McpRuntimeContext,
|
||||
available_environment_ids: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
plugins_available,
|
||||
manager,
|
||||
runtime_context,
|
||||
available_environment_ids,
|
||||
@@ -32,6 +35,10 @@ impl McpRuntimeSnapshot {
|
||||
self.config.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn plugins_available(&self) -> bool {
|
||||
self.plugins_available
|
||||
}
|
||||
|
||||
pub fn manager(&self) -> &McpConnectionManager {
|
||||
self.manager.as_ref()
|
||||
}
|
||||
@@ -86,6 +93,7 @@ impl McpRuntimeSnapshot {
|
||||
);
|
||||
Arc::new(Self::new(
|
||||
Arc::new(mcp_config),
|
||||
/*plugins_available*/ false,
|
||||
Arc::new(manager),
|
||||
runtime_context,
|
||||
Vec::new(),
|
||||
|
||||
@@ -14,15 +14,15 @@ use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::attestation::AttestationProvider;
|
||||
use crate::build_available_skills;
|
||||
use crate::compact;
|
||||
use crate::config::ManagedFeatures;
|
||||
use crate::config::resolve_tool_suggest_config_from_layer_stack;
|
||||
use crate::connectors;
|
||||
use crate::context::ApprovalPromptContext;
|
||||
use crate::context::ApprovedCommandPrefixSaved;
|
||||
use crate::context::AppsInstructions;
|
||||
use crate::context::AvailablePluginsInstructions;
|
||||
use crate::context::AvailableSkillsInstructions;
|
||||
use crate::context::CollaborationModeInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
@@ -92,7 +92,6 @@ use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -122,6 +121,7 @@ use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
@@ -419,6 +419,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
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) requested_history_mode: Option<ThreadHistoryMode>,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) forked_from_thread_id: Option<ThreadId>,
|
||||
pub(crate) parent_thread_id: Option<ThreadId>,
|
||||
@@ -510,6 +511,7 @@ impl Codex {
|
||||
code_mode_session_provider,
|
||||
extensions,
|
||||
conversation_history,
|
||||
requested_history_mode,
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
@@ -600,6 +602,9 @@ impl Codex {
|
||||
.await;
|
||||
let multi_agent_version =
|
||||
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()),
|
||||
);
|
||||
config
|
||||
.validate_multi_agent_v2_config()
|
||||
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
||||
@@ -625,11 +630,14 @@ impl Codex {
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let service_tier = get_service_tier(
|
||||
config.service_tier.clone(),
|
||||
config.features.enabled(Feature::FastMode),
|
||||
let fast_mode_enabled = config.features.enabled(Feature::FastMode);
|
||||
let initial_service_tier_warning = unsupported_service_tier_warning(
|
||||
config.service_tier.as_deref(),
|
||||
fast_mode_enabled,
|
||||
&model_info,
|
||||
);
|
||||
let service_tier =
|
||||
get_service_tier(config.service_tier.clone(), fast_mode_enabled, &model_info);
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
collaboration_mode,
|
||||
@@ -655,6 +663,7 @@ impl Codex {
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source,
|
||||
history_mode,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
thread_source,
|
||||
@@ -701,6 +710,14 @@ impl Codex {
|
||||
error!("Failed to create session: {e:#}");
|
||||
map_session_init_error(&e, &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.
|
||||
@@ -903,6 +920,22 @@ fn get_service_tier(
|
||||
})
|
||||
}
|
||||
|
||||
fn unsupported_service_tier_warning(
|
||||
configured_service_tier: Option<&str>,
|
||||
fast_mode_enabled: bool,
|
||||
model_info: &ModelInfo,
|
||||
) -> Option<String> {
|
||||
let service_tier = configured_service_tier.filter(|service_tier| {
|
||||
fast_mode_enabled
|
||||
&& *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> {
|
||||
@@ -1134,12 +1167,8 @@ impl Session {
|
||||
state.session_configuration.codex_home().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_out_of_band_elicitation_pause_state(&self) -> watch::Receiver<bool> {
|
||||
self.out_of_band_elicitation_paused.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn set_out_of_band_elicitation_pause_state(&self, paused: bool) {
|
||||
self.out_of_band_elicitation_paused.send_replace(paused);
|
||||
pub(crate) fn subscribe_elicitation_pause_state(&self) -> watch::Receiver<bool> {
|
||||
self.services.elicitations.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn get_tx_event(&self) -> Sender<Event> {
|
||||
@@ -1766,6 +1795,9 @@ impl Session {
|
||||
|
||||
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,
|
||||
@@ -1860,10 +1892,12 @@ impl Session {
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let context =
|
||||
AgentCommunicationContext::new(AgentCommunicationKind::Result, self.thread_id);
|
||||
if let Err(err) = self
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.send_inter_agent_communication(parent_thread_id, communication, context)
|
||||
.await
|
||||
{
|
||||
debug!("failed to notify parent thread {parent_thread_id}: {err}");
|
||||
@@ -2824,6 +2858,7 @@ impl Session {
|
||||
/// 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.
|
||||
#[tracing::instrument(name = "step_context.capture", level = "info", skip_all)]
|
||||
pub(crate) async fn capture_step_context(
|
||||
self: &Arc<Self>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
@@ -3172,7 +3207,14 @@ impl Session {
|
||||
PermissionsInstructions::from_permission_profile(
|
||||
&turn_context.permission_profile,
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
ApprovalPromptContext::new(
|
||||
turn_context.config.approvals_reviewer,
|
||||
turn_context
|
||||
.model_info
|
||||
.model_messages
|
||||
.as_ref()
|
||||
.and_then(|messages| messages.approvals.as_ref()),
|
||||
),
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
#[allow(deprecated)]
|
||||
&turn_context.cwd,
|
||||
@@ -3229,19 +3271,6 @@ impl Session {
|
||||
.push(PersonalitySpecInstructions::new(personality_message).render());
|
||||
}
|
||||
}
|
||||
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
|
||||
let accessible_and_enabled_connectors =
|
||||
connectors::list_accessible_and_enabled_connectors_from_manager(
|
||||
mcp.manager(),
|
||||
&turn_context.config,
|
||||
)
|
||||
.await;
|
||||
if let Some(apps_instructions) =
|
||||
AppsInstructions::from_connectors(&accessible_and_enabled_connectors)
|
||||
{
|
||||
developer_sections.push(apps_instructions.render());
|
||||
}
|
||||
}
|
||||
if turn_context.config.include_skill_instructions {
|
||||
let available_skills = build_available_skills(
|
||||
turn_context.turn_skills.snapshot.outcome(),
|
||||
@@ -3252,7 +3281,10 @@ impl Session {
|
||||
);
|
||||
if let Some(available_skills) = available_skills {
|
||||
let warning_message = available_skills.warning_message.clone();
|
||||
let skills_instructions = AvailableSkillsInstructions::from(available_skills);
|
||||
let skills_instructions = AvailableSkillsInstructions::from_available_skills(
|
||||
&available_skills,
|
||||
turn_context.model_info.include_skills_usage_instructions,
|
||||
);
|
||||
if let Some(warning_message) = warning_message {
|
||||
self.send_event_raw(Event {
|
||||
id: String::new(),
|
||||
@@ -3293,11 +3325,6 @@ impl Session {
|
||||
{
|
||||
contextual_user_sections.push(recommended_plugins.render());
|
||||
}
|
||||
if let Some(plugin_instructions) =
|
||||
AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries())
|
||||
{
|
||||
developer_sections.push(plugin_instructions.render());
|
||||
}
|
||||
let context_contributors = self.services.extensions.context_contributors().to_vec();
|
||||
for contributor in &context_contributors {
|
||||
for fragment in contributor
|
||||
@@ -3416,16 +3443,10 @@ impl Session {
|
||||
{
|
||||
items.push(usage_hint_message);
|
||||
}
|
||||
match multi_agents::effective_multi_agent_mode(turn_context) {
|
||||
Some(
|
||||
multi_agent_mode
|
||||
@ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive),
|
||||
) => {
|
||||
items.push(ContextualUserFragment::into(
|
||||
MultiAgentModeInstructions::new(multi_agent_mode),
|
||||
));
|
||||
}
|
||||
Some(MultiAgentMode::None) | None => {}
|
||||
if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode(turn_context) {
|
||||
items.push(ContextualUserFragment::into(
|
||||
MultiAgentModeInstructions::new(multi_agent_mode),
|
||||
));
|
||||
}
|
||||
if let Some(contextual_user_message) =
|
||||
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
|
||||
|
||||
@@ -41,9 +41,18 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option<M
|
||||
return None;
|
||||
}
|
||||
|
||||
let multi_agent_mode = match turn_context.effective_reasoning_effort() {
|
||||
Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive,
|
||||
_ => MultiAgentMode::ExplicitRequestOnly,
|
||||
// A configured hint, including an empty string, defines a custom policy instead of an
|
||||
// effort-derived built-in policy.
|
||||
let multi_agent_mode = match &turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.multi_agent_mode_hint_text
|
||||
{
|
||||
Some(hint_text) => MultiAgentMode::Custom(hint_text.clone()),
|
||||
None => match turn_context.effective_reasoning_effort() {
|
||||
Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive,
|
||||
_ => MultiAgentMode::ExplicitRequestOnly,
|
||||
},
|
||||
};
|
||||
|
||||
match &turn_context.session_source {
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||
use std::sync::OnceLock;
|
||||
@@ -29,7 +30,6 @@ pub(crate) struct Session {
|
||||
pub(crate) installation_id: String,
|
||||
pub(super) tx_event: Sender<Event>,
|
||||
pub(super) agent_status: watch::Sender<AgentStatus>,
|
||||
pub(super) out_of_band_elicitation_paused: watch::Sender<bool>,
|
||||
pub(super) state: Mutex<SessionState>,
|
||||
/// Serializes rebuild/apply cycles for the running proxy; each cycle
|
||||
/// rebuilds from the current SessionState while holding this lock.
|
||||
@@ -96,6 +96,8 @@ pub(crate) struct SessionConfiguration {
|
||||
pub(super) app_server_client_version: Option<String>,
|
||||
/// Source of the session (cli, vscode, exec, mcp, ...)
|
||||
pub(super) session_source: SessionSource,
|
||||
/// Persisted thread history contract selected when this thread was created.
|
||||
pub(super) history_mode: ThreadHistoryMode,
|
||||
/// Immediate history source copied into this thread, when this thread was forked.
|
||||
pub(super) forked_from_thread_id: Option<ThreadId>,
|
||||
/// Immediate control/spawn parent for this thread, when it has one.
|
||||
@@ -192,6 +194,7 @@ impl SessionConfiguration {
|
||||
personality: self.personality,
|
||||
collaboration_mode: self.collaboration_mode.clone(),
|
||||
session_source: self.session_source.clone(),
|
||||
history_mode: self.history_mode,
|
||||
forked_from_thread_id: self.forked_from_thread_id,
|
||||
parent_thread_id: self.parent_thread_id,
|
||||
thread_source: self.thread_source.clone(),
|
||||
@@ -599,6 +602,7 @@ impl Session {
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
selected_capability_roots: selected_capability_roots.clone(),
|
||||
multi_agent_version: initial_multi_agent_version,
|
||||
history_mode: session_configuration.history_mode,
|
||||
initial_window_id: initial_auto_compact_window_ids
|
||||
.window_id
|
||||
.to_string(),
|
||||
@@ -674,7 +678,7 @@ impl Session {
|
||||
let mcp_runtime_context_for_auth = mcp_runtime_context.clone();
|
||||
let auth_and_mcp_fut = async move {
|
||||
let auth = auth_manager_clone.auth().await;
|
||||
let mcp_config = mcp_manager_for_mcp
|
||||
let mcp_projection = mcp_manager_for_mcp
|
||||
.runtime_config_for_step(
|
||||
&config_for_mcp,
|
||||
mcp_thread_init_for_startup,
|
||||
@@ -682,8 +686,9 @@ impl Session {
|
||||
/*available_environment_ids*/ &[],
|
||||
)
|
||||
.await;
|
||||
let mcp_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config);
|
||||
let mcp_config = &mcp_projection.config;
|
||||
let mcp_servers = codex_mcp::effective_mcp_servers(mcp_config, auth.as_ref());
|
||||
let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(mcp_config);
|
||||
let auth_statuses = compute_auth_statuses(
|
||||
mcp_servers.iter(),
|
||||
config_for_mcp.mcp_oauth_credentials_store_mode,
|
||||
@@ -694,7 +699,7 @@ impl Session {
|
||||
.await;
|
||||
(
|
||||
auth,
|
||||
mcp_config,
|
||||
mcp_projection,
|
||||
mcp_servers,
|
||||
auth_statuses,
|
||||
tool_plugin_provenance,
|
||||
@@ -709,7 +714,7 @@ impl Session {
|
||||
let (
|
||||
thread_persistence_result,
|
||||
state_db_ctx,
|
||||
(auth, mcp_config, mcp_servers, auth_statuses, tool_plugin_provenance),
|
||||
(auth, mcp_projection, mcp_servers, auth_statuses, tool_plugin_provenance),
|
||||
) = tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut);
|
||||
|
||||
let mut live_thread_init =
|
||||
@@ -1051,6 +1056,7 @@ impl Session {
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
elicitations: crate::elicitation::ElicitationService::new(),
|
||||
shell_zsh_path: config.zsh_path.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client,
|
||||
@@ -1105,7 +1111,11 @@ impl Session {
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
|
||||
/*concurrent_reasoning_summaries_enabled*/ config
|
||||
.features
|
||||
.enabled(Feature::ConcurrentReasoningSummaries),
|
||||
attestation_provider,
|
||||
config.http_client_factory(),
|
||||
)
|
||||
.with_prompt_cache_key_override(
|
||||
crate::guardian::prompt_cache_key_override_for_review_session(
|
||||
@@ -1119,15 +1129,11 @@ impl Session {
|
||||
tool_search_handler_cache: Default::default(),
|
||||
turn_environments: Arc::clone(&turn_environments),
|
||||
};
|
||||
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
|
||||
watch::channel(false);
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
thread_id,
|
||||
installation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
agent_status,
|
||||
out_of_band_elicitation_paused,
|
||||
state: Mutex::new(state),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
@@ -1203,13 +1209,17 @@ impl Session {
|
||||
sess.services.mcp_manager.codex_apps_tools_cache(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
config.prefix_mcp_tool_names(),
|
||||
mcp_config.client_elicitation_capability.clone(),
|
||||
mcp_projection
|
||||
.config
|
||||
.client_elicitation_capability
|
||||
.clone(),
|
||||
sess.services
|
||||
.supports_openai_form_elicitation
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
Some(sess.mcp_elicitation_reviewer()),
|
||||
Some(sess.mcp_elicitation_lifecycle()),
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.instrument(info_span!(
|
||||
@@ -1219,7 +1229,8 @@ impl Session {
|
||||
.await;
|
||||
sess.services
|
||||
.install_mcp_connection_manager(
|
||||
Arc::new(mcp_config),
|
||||
Arc::new(mcp_projection.config),
|
||||
mcp_projection.plugins_available,
|
||||
mcp_runtime_context,
|
||||
/*available_environment_ids*/ Vec::new(),
|
||||
mcp_connection_manager,
|
||||
|
||||
@@ -33,6 +33,8 @@ use codex_core_skills::HostSkillsSnapshot;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
|
||||
use codex_features::Feature;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth::AgentIdentityAuthPolicy;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
@@ -490,7 +492,9 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
|
||||
/*include_timing_metrics*/ false,
|
||||
/*beta_features_header*/ None,
|
||||
/*item_ids_enabled*/ false,
|
||||
/*concurrent_reasoning_summaries_enabled*/ false,
|
||||
/*attestation_provider*/ None,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
)
|
||||
.new_session()
|
||||
}
|
||||
@@ -1039,6 +1043,22 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
|
||||
) -> futures::future::BoxFuture<'a, ReviewDecision> {
|
||||
Box::pin(async { ReviewDecision::Approved })
|
||||
}
|
||||
|
||||
fn approval_action(
|
||||
&self,
|
||||
_req: &(),
|
||||
ctx: &crate::tools::sandboxing::ApprovalCtx<'_>,
|
||||
) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> {
|
||||
Ok(crate::tools::sandboxing::ApprovalAction::Shell {
|
||||
id: ctx.call_id.to_string(),
|
||||
command: Vec::new(),
|
||||
#[allow(deprecated)]
|
||||
cwd: ctx.turn.cwd.clone(),
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime {
|
||||
@@ -3686,6 +3706,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -3792,6 +3813,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -4050,6 +4072,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
dynamic_tools: Vec::new(),
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: Default::default(),
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
@@ -4323,6 +4346,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -5193,6 +5217,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -5324,6 +5349,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -5378,6 +5404,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
elicitations: crate::elicitation::ElicitationService::new(),
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
@@ -5437,7 +5464,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
|
||||
/*concurrent_reasoning_summaries_enabled*/
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::ConcurrentReasoningSummaries),
|
||||
/*attestation_provider*/ None,
|
||||
config.http_client_factory(),
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new(
|
||||
codex_code_mode::InProcessCodeModeSessionProvider,
|
||||
@@ -5488,7 +5520,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
out_of_band_elicitation_paused: watch::channel(false).0,
|
||||
state: Mutex::new(state),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
@@ -5572,6 +5603,7 @@ async fn make_session_with_config_and_rx(
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -5679,6 +5711,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: session_source.clone(),
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -6931,6 +6964,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
dynamic_tools: Vec::new(),
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: Default::default(),
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
@@ -6962,7 +6996,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
|
||||
async fn submission_loop_channel_close_runs_full_thread_teardown() {
|
||||
struct SessionStopMarker;
|
||||
struct ThreadStopMarker;
|
||||
|
||||
@@ -6989,6 +7023,41 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
|
||||
}
|
||||
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
||||
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
||||
let config = session.get_config().await;
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&thread_store),
|
||||
CreateThreadParams {
|
||||
session_id: session.session_id(),
|
||||
thread_id: session.thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
thread_source: None,
|
||||
originator: "test_originator".to_string(),
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: Default::default(),
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: config.model_provider_id.clone(),
|
||||
memory_mode: if config.memories.generate_memories {
|
||||
ThreadMemoryMode::Enabled
|
||||
} else {
|
||||
ThreadMemoryMode::Disabled
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create thread persistence");
|
||||
session.services.thread_store = thread_store;
|
||||
session.services.live_thread = Some(live_thread);
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
||||
builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder {
|
||||
@@ -7011,6 +7080,14 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
|
||||
submission_loop(session, Arc::clone(&turn_context.config), rx_sub).await;
|
||||
|
||||
assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert_eq!(
|
||||
codex_thread_store::InMemoryThreadStoreCalls {
|
||||
create_thread: 1,
|
||||
shutdown_thread: 1,
|
||||
..Default::default()
|
||||
},
|
||||
store.calls().await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -7404,6 +7481,7 @@ where
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
history_mode: Default::default(),
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
@@ -7457,6 +7535,7 @@ where
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
),
|
||||
elicitations: crate::elicitation::ElicitationService::new(),
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
@@ -7516,7 +7595,12 @@ where
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
/*item_ids_enabled*/ config.features.enabled(Feature::ItemIds),
|
||||
/*concurrent_reasoning_summaries_enabled*/
|
||||
config
|
||||
.features
|
||||
.enabled(Feature::ConcurrentReasoningSummaries),
|
||||
/*attestation_provider*/ None,
|
||||
config.http_client_factory(),
|
||||
),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new(
|
||||
codex_code_mode::InProcessCodeModeSessionProvider,
|
||||
@@ -7567,7 +7651,6 @@ where
|
||||
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
out_of_band_elicitation_paused: watch::channel(false).0,
|
||||
state: Mutex::new(state),
|
||||
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
||||
features: config.features.clone(),
|
||||
@@ -7676,6 +7759,91 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_availability_change_reuses_the_mcp_manager() {
|
||||
struct ReadyPluginContributor;
|
||||
|
||||
impl codex_extension_api::McpServerContributor<Config> for ReadyPluginContributor {
|
||||
fn id(&self) -> &'static str {
|
||||
"ready_plugin_test"
|
||||
}
|
||||
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let available = context
|
||||
.available_environment_ids()
|
||||
.is_some_and(|ids| ids.iter().any(|id| id == "executor"));
|
||||
available
|
||||
.then(
|
||||
|| codex_extension_api::McpServerContribution::SelectedPluginPackage {
|
||||
plugin_id: "skill-only".to_string(),
|
||||
plugin_display_name: "Skill Only".to_string(),
|
||||
connector_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.into_iter()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
let mut registry = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
registry.mcp_server_contributor(Arc::new(ReadyPluginContributor));
|
||||
let registry = Arc::new(registry.build());
|
||||
session.services.extensions = Arc::clone(®istry);
|
||||
session.services.mcp_manager = Arc::new(McpManager::new_with_extensions(
|
||||
Arc::clone(&session.services.plugins_manager),
|
||||
registry,
|
||||
));
|
||||
let session = Arc::new(session);
|
||||
session
|
||||
.refresh_mcp_servers_now(
|
||||
&turn_context,
|
||||
&turn_context.config,
|
||||
/*elicitation_reviewer*/ None,
|
||||
)
|
||||
.await;
|
||||
let old_runtime = session.services.latest_mcp_runtime();
|
||||
let old_manager = old_runtime.manager_arc();
|
||||
|
||||
let selected_root = codex_protocol::capabilities::SelectedCapabilityRoot {
|
||||
id: "skill-only".to_string(),
|
||||
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
|
||||
environment_id: "executor".to_string(),
|
||||
path: PathUri::from_host_native_path(turn_context.config.cwd.as_path())
|
||||
.expect("selected capability root URI"),
|
||||
},
|
||||
};
|
||||
let environment = Arc::clone(
|
||||
&turn_context
|
||||
.environments
|
||||
.primary()
|
||||
.expect("ready test environment")
|
||||
.environment,
|
||||
);
|
||||
let captured_environments = HashMap::from([("executor".to_string(), Some(environment))]);
|
||||
let resolved_roots = session
|
||||
.services
|
||||
.turn_environments
|
||||
.environment_manager()
|
||||
.resolve_selected_capability_roots(&[selected_root], &captured_environments)
|
||||
.await;
|
||||
|
||||
let new_runtime = session
|
||||
.mcp_runtime_for_step(&turn_context, &turn_context.environments, &resolved_roots)
|
||||
.await;
|
||||
|
||||
assert!(!old_runtime.plugins_available());
|
||||
assert!(new_runtime.plugins_available());
|
||||
assert!(!Arc::ptr_eq(&old_runtime, &new_runtime));
|
||||
assert!(Arc::ptr_eq(&old_manager, &new_runtime.manager_arc()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn built_tools_uses_the_step_mcp_runtime() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -8873,18 +9041,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co
|
||||
#[tokio::test]
|
||||
async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs()
|
||||
{
|
||||
let (mut session, previous_context) = make_session_and_context().await;
|
||||
let next_model = if previous_context.model_info.slug == "gpt-5.4" {
|
||||
"gpt-5.2"
|
||||
} else {
|
||||
"gpt-5.4"
|
||||
};
|
||||
let turn_context = previous_context
|
||||
.with_model(next_model.to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let previous_context_item = previous_context.to_turn_context_item();
|
||||
let previous_context = Arc::new(previous_context);
|
||||
let (mut session, turn_context) = make_session_and_context().await;
|
||||
let previous_context_item = turn_context.to_turn_context_item();
|
||||
let previous_context = Arc::new(turn_context);
|
||||
let world_state = build_world_state_from_turn_context(&session, &previous_context).await;
|
||||
let mut turn_context = Arc::try_unwrap(previous_context)
|
||||
.unwrap_or_else(|_| panic!("previous turn context should have no remaining references"));
|
||||
turn_context.sub_id = format!("{}-next", turn_context.sub_id);
|
||||
{
|
||||
let mut state = session.state.lock().await;
|
||||
state.set_reference_context_item(Some(previous_context_item.clone()));
|
||||
@@ -9187,6 +9350,7 @@ async fn attach_in_memory_thread_store(
|
||||
dynamic_tools: Vec::new(),
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: Default::default(),
|
||||
initial_window_id: Uuid::now_v7().to_string(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
@@ -10428,6 +10592,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
status: None,
|
||||
call_id: "call-1".to_string(),
|
||||
name: "shell_command".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
@@ -730,6 +730,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
code_mode_session_provider: Arc::new(codex_code_mode::InProcessCodeModeSessionProvider),
|
||||
extensions: codex_extension_api::empty_extension_registry(),
|
||||
conversation_history: InitialHistory::New,
|
||||
requested_history_mode: None,
|
||||
session_source: SessionSource::SubAgent(SubAgentSource::Other(
|
||||
GUARDIAN_REVIEWER_NAME.to_string(),
|
||||
)),
|
||||
|
||||
@@ -167,9 +167,10 @@ pub(crate) async fn run_turn(
|
||||
// run_turn owns the step used to seed context and make the first sampling request.
|
||||
let first_step_context = sess.capture_step_context(Arc::clone(&turn_context)).await;
|
||||
// Keep the exact model-visible state used by this turn and its inline compactions.
|
||||
let mut world_state = sess
|
||||
.record_context_updates_and_set_reference_context_item(first_step_context.as_ref())
|
||||
.await;
|
||||
let (mut world_state, display_roots) = tokio::join!(
|
||||
sess.record_context_updates_and_set_reference_context_item(first_step_context.as_ref()),
|
||||
turn_diff_display_roots(turn_context.as_ref()),
|
||||
);
|
||||
|
||||
let Some((injection_items, explicitly_enabled_connectors)) = build_skills_and_plugins(
|
||||
&sess,
|
||||
@@ -209,7 +210,6 @@ pub(crate) async fn run_turn(
|
||||
let mut stop_hook_active = false;
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
let display_roots = turn_diff_display_roots(turn_context.as_ref()).await;
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(
|
||||
TurnDiffTracker::with_environment_display_roots(display_roots),
|
||||
));
|
||||
@@ -349,6 +349,7 @@ pub(crate) async fn run_turn(
|
||||
if let Err(err) = run_auto_compact(
|
||||
&sess,
|
||||
Arc::clone(&step_context),
|
||||
/*fallback_step_context*/ None,
|
||||
&mut client_session,
|
||||
InitialContextInjection::BeforeLastUserMessage(Arc::clone(&world_state)),
|
||||
CompactionReason::ContextLimit,
|
||||
@@ -810,6 +811,7 @@ async fn run_pre_sampling_compact(
|
||||
run_auto_compact(
|
||||
sess,
|
||||
step_context,
|
||||
/*fallback_step_context*/ None,
|
||||
client_session,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionReason::ContextLimit,
|
||||
@@ -828,6 +830,28 @@ fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool {
|
||||
.is_some_and(|(previous, current)| previous != current)
|
||||
}
|
||||
|
||||
/// Captures the current model's request-scoped state for retrying previous-model compaction.
|
||||
///
|
||||
/// Returns `None` when the active authentication does not use the Codex backend, the provider is
|
||||
/// not OpenAI, or the previous and current model are the same.
|
||||
async fn capture_current_model_fallback_step_context(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
previous_model: &str,
|
||||
) -> Option<Arc<StepContext>> {
|
||||
let uses_codex_backend = turn_context
|
||||
.auth_manager
|
||||
.as_deref()
|
||||
.is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend);
|
||||
if !uses_codex_backend
|
||||
|| !turn_context.provider.info().is_openai()
|
||||
|| previous_model == turn_context.model_info.slug
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(sess.capture_step_context(Arc::clone(turn_context)).await)
|
||||
}
|
||||
|
||||
/// Runs pre-sampling compaction against the previous model when its compaction compatibility
|
||||
/// hash changed or when switching to a smaller context-window model.
|
||||
///
|
||||
@@ -844,20 +868,27 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
previous_turn_settings.comp_hash.as_deref(),
|
||||
turn_context.model_info.comp_hash.as_deref(),
|
||||
);
|
||||
let previous_model = previous_turn_settings.model;
|
||||
let previous_model_turn_context = Arc::new(
|
||||
turn_context
|
||||
.with_model(previous_turn_settings.model, &sess.services.models_manager)
|
||||
.with_model(previous_model.clone(), &sess.services.models_manager)
|
||||
.await,
|
||||
);
|
||||
|
||||
if should_compact_for_comp_hash_change {
|
||||
// This pre-turn request needs a step built from the previous model's turn context.
|
||||
let step_context = sess
|
||||
.capture_step_context(Arc::clone(&previous_model_turn_context))
|
||||
.await;
|
||||
let fallback_step_context = capture_current_model_fallback_step_context(
|
||||
sess,
|
||||
turn_context,
|
||||
previous_model.as_str(),
|
||||
)
|
||||
.await;
|
||||
run_auto_compact(
|
||||
sess,
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
client_session,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionReason::CompHashChanged,
|
||||
@@ -892,13 +923,19 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
|
||||
&& old_context_window > new_context_window;
|
||||
if should_run {
|
||||
// This pre-turn request needs a step built from the previous model's turn context.
|
||||
let step_context = sess
|
||||
.capture_step_context(Arc::clone(&previous_model_turn_context))
|
||||
.await;
|
||||
let fallback_step_context = capture_current_model_fallback_step_context(
|
||||
sess,
|
||||
turn_context,
|
||||
previous_model.as_str(),
|
||||
)
|
||||
.await;
|
||||
run_auto_compact(
|
||||
sess,
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
client_session,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionReason::ModelDownshift,
|
||||
@@ -917,6 +954,7 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
async fn run_auto_compact(
|
||||
sess: &Arc<Session>,
|
||||
step_context: Arc<StepContext>,
|
||||
fallback_step_context: Option<Arc<StepContext>>,
|
||||
client_session: &mut ModelClientSession,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
reason: CompactionReason,
|
||||
@@ -949,6 +987,7 @@ async fn run_auto_compact(
|
||||
run_inline_remote_auto_compact_task_v2(
|
||||
Arc::clone(sess),
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
client_session,
|
||||
initial_context_injection,
|
||||
reason,
|
||||
@@ -965,6 +1004,7 @@ async fn run_auto_compact(
|
||||
run_inline_remote_auto_compact_task(
|
||||
Arc::clone(sess),
|
||||
step_context,
|
||||
fallback_step_context,
|
||||
client_session.turn_state(),
|
||||
initial_context_injection,
|
||||
reason,
|
||||
@@ -1909,6 +1949,11 @@ async fn try_run_sampling_request(
|
||||
turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
|
||||
let uses_sequential_cutoff_reasoning_summaries = turn_context
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::ConcurrentReasoningSummaries)
|
||||
&& turn_context.provider.info().is_openai();
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
prompt,
|
||||
@@ -2080,8 +2125,14 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
}
|
||||
ResponseEvent::OutputItemAdded(item) => {
|
||||
if let ResponseItem::CustomToolCall { call_id, name, .. } = &item {
|
||||
let tool_name = ToolName::plain(name.as_str());
|
||||
if let ResponseItem::CustomToolCall {
|
||||
call_id,
|
||||
name,
|
||||
namespace,
|
||||
..
|
||||
} = &item
|
||||
{
|
||||
let tool_name = ToolName::new(namespace.clone(), name.as_str());
|
||||
active_tool_argument_diff_consumer = tool_runtime
|
||||
.create_diff_consumer(&tool_name)
|
||||
.map(|consumer| (call_id.clone(), consumer));
|
||||
@@ -2285,6 +2336,9 @@ async fn try_run_sampling_request(
|
||||
delta,
|
||||
summary_index,
|
||||
} => {
|
||||
if uses_sequential_cutoff_reasoning_summaries {
|
||||
continue;
|
||||
}
|
||||
if let Some(active) = active_item.as_ref() {
|
||||
if !active_item_is_streaming_to_client {
|
||||
continue;
|
||||
@@ -2303,6 +2357,9 @@ async fn try_run_sampling_request(
|
||||
}
|
||||
}
|
||||
ResponseEvent::ReasoningSummaryPartAdded { summary_index } => {
|
||||
if uses_sequential_cutoff_reasoning_summaries {
|
||||
continue;
|
||||
}
|
||||
if let Some(active) = active_item.as_ref() {
|
||||
if !active_item_is_streaming_to_client {
|
||||
continue;
|
||||
@@ -2317,6 +2374,40 @@ async fn try_run_sampling_request(
|
||||
error_or_panic("ReasoningSummaryPartAdded without active item".to_string());
|
||||
}
|
||||
}
|
||||
ResponseEvent::ReasoningSummaryDone {
|
||||
item_id,
|
||||
text,
|
||||
summary_index,
|
||||
} => {
|
||||
if !uses_sequential_cutoff_reasoning_summaries {
|
||||
continue;
|
||||
}
|
||||
let Some(active) = active_item.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if !active_item_is_streaming_to_client || active.id() != item_id {
|
||||
continue;
|
||||
}
|
||||
if summary_index > 0 {
|
||||
sess.send_event(
|
||||
&turn_context,
|
||||
EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {
|
||||
item_id: item_id.clone(),
|
||||
summary_index,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let event = ReasoningContentDeltaEvent {
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id,
|
||||
delta: text,
|
||||
summary_index,
|
||||
};
|
||||
sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event))
|
||||
.await;
|
||||
}
|
||||
ResponseEvent::ReasoningContentDelta {
|
||||
delta,
|
||||
content_index,
|
||||
|
||||
@@ -701,6 +701,9 @@ impl Session {
|
||||
&per_turn_config.to_models_manager_config(),
|
||||
)
|
||||
.await;
|
||||
self.services
|
||||
.thread_extension_data
|
||||
.insert(model_info.clone());
|
||||
|
||||
let multi_agent_version = match multi_agent_runtime {
|
||||
TurnMultiAgentRuntime::ResolveAndStore => {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use super::session::Session;
|
||||
use super::step_context::StepContext;
|
||||
use crate::connectors;
|
||||
use crate::context::world_state::AgentsMdState;
|
||||
use crate::context::world_state::AppsInstructionsState;
|
||||
use crate::context::world_state::EnvironmentsState;
|
||||
use crate::context::world_state::PluginsInstructionsState;
|
||||
use crate::context::world_state::WorldState;
|
||||
use codex_extension_api::WorldStateContributionInput;
|
||||
|
||||
impl Session {
|
||||
#[tracing::instrument(name = "world_state.build", level = "info", skip_all)]
|
||||
pub(crate) async fn build_world_state_for_step(
|
||||
&self,
|
||||
step_context: &StepContext,
|
||||
@@ -35,6 +39,22 @@ impl Session {
|
||||
.with_subagents(environment_subagents),
|
||||
);
|
||||
}
|
||||
let apps_available =
|
||||
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
|
||||
let tools = step_context.mcp.manager().list_all_tools().await;
|
||||
connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&tools),
|
||||
&turn_context.config,
|
||||
)
|
||||
.into_iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
world_state.add_section(AppsInstructionsState::new(apps_available));
|
||||
world_state.add_section(PluginsInstructionsState::new(
|
||||
step_context.mcp.plugins_available(),
|
||||
));
|
||||
let environments = step_context.environments.to_selections();
|
||||
let ready_selected_capability_roots = step_context
|
||||
.selected_capability_roots
|
||||
|
||||
@@ -3,8 +3,16 @@ use std::path::Path;
|
||||
|
||||
use crate::rollout::SESSIONS_SUBDIR;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_thread_store::ThreadStoreError;
|
||||
|
||||
pub(crate) fn map_session_init_error(err: &anyhow::Error, codex_home: &Path) -> CodexErr {
|
||||
if let Some(ThreadStoreError::Unsupported { operation }) = err
|
||||
.chain()
|
||||
.find_map(|cause| cause.downcast_ref::<ThreadStoreError>())
|
||||
{
|
||||
return CodexErr::UnsupportedOperation(format!("{operation} is not supported yet"));
|
||||
}
|
||||
|
||||
if let Some(mapped) = err
|
||||
.chain()
|
||||
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::client::ModelClient;
|
||||
use crate::config::NetworkProxyAuditMetadata;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::current_time::TimeProvider;
|
||||
use crate::elicitation::ElicitationService;
|
||||
use crate::environment_selection::ThreadEnvironments;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::guardian::GuardianRejection;
|
||||
@@ -55,6 +56,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_projection_lock: Mutex<()>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
pub(crate) elicitations: ElicitationService,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) shell_zsh_path: Option<PathBuf>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
@@ -107,18 +109,25 @@ impl SessionServices {
|
||||
pub(crate) async fn install_mcp_connection_manager(
|
||||
&self,
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
runtime_context: McpRuntimeContext,
|
||||
available_environment_ids: Vec<String>,
|
||||
manager: McpConnectionManager,
|
||||
) -> Result<()> {
|
||||
let runtime =
|
||||
self.publish_mcp_runtime(config, runtime_context, available_environment_ids, manager);
|
||||
let runtime = self.publish_mcp_runtime(
|
||||
config,
|
||||
plugins_available,
|
||||
runtime_context,
|
||||
available_environment_ids,
|
||||
manager,
|
||||
);
|
||||
runtime.manager().validate_required_servers().await
|
||||
}
|
||||
|
||||
pub(crate) fn publish_mcp_runtime(
|
||||
&self,
|
||||
config: Arc<McpConfig>,
|
||||
plugins_available: bool,
|
||||
runtime_context: McpRuntimeContext,
|
||||
available_environment_ids: Vec<String>,
|
||||
manager: McpConnectionManager,
|
||||
@@ -129,6 +138,7 @@ impl SessionServices {
|
||||
self.mcp_connection_manager.store(Arc::clone(&manager));
|
||||
let runtime = Arc::new(McpRuntimeSnapshot::new(
|
||||
config,
|
||||
plugins_available,
|
||||
manager,
|
||||
runtime_context,
|
||||
available_environment_ids,
|
||||
|
||||
@@ -116,6 +116,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() {
|
||||
status: None,
|
||||
call_id: "custom-1".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
namespace: None,
|
||||
input: "*** Begin Patch\n*** End Patch\n".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
|
||||
@@ -25,16 +25,15 @@ use crate::tools::runtimes::RuntimePathPrepends;
|
||||
use crate::tools::runtimes::apply_package_path_prepend;
|
||||
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
|
||||
use crate::tools::runtimes::strip_managed_proxy_env;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use crate::user_shell_command::user_shell_command_record_item;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::items::CommandExecutionItem;
|
||||
use codex_protocol::items::CommandExecutionStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
use codex_protocol::protocol::ExecCommandEndEvent;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_protocol::protocol::ExecCommandStatus;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
@@ -181,18 +180,23 @@ pub(crate) async fn execute_user_shell_command(
|
||||
|
||||
let parsed_cmd = parse_command(&display_command);
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
&TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id.clone(),
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration: None,
|
||||
formatted_output: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -259,57 +263,53 @@ pub(crate) async fn execute_user_shell_command(
|
||||
)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id,
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: aborted_message.clone(),
|
||||
aggregated_output: aborted_message.clone(),
|
||||
exit_code: -1,
|
||||
duration: Duration::ZERO,
|
||||
formatted_output: aborted_message,
|
||||
status: ExecCommandStatus::Failed,
|
||||
status: CommandExecutionStatus::Failed,
|
||||
stdout: Some(String::new()),
|
||||
stderr: Some(aborted_message.clone()),
|
||||
aggregated_output: Some(aborted_message.clone()),
|
||||
exit_code: Some(-1),
|
||||
duration: Some(Duration::ZERO),
|
||||
formatted_output: Some(aborted_message),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id.clone(),
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone().into(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: output.stdout.text.clone(),
|
||||
stderr: output.stderr.text.clone(),
|
||||
aggregated_output: output.aggregated_output.text.clone(),
|
||||
exit_code: output.exit_code,
|
||||
duration: output.duration,
|
||||
formatted_output: format_exec_output_str(
|
||||
status: if output.exit_code == 0 {
|
||||
CommandExecutionStatus::Completed
|
||||
} else {
|
||||
CommandExecutionStatus::Failed
|
||||
},
|
||||
stdout: Some(output.stdout.text.clone()),
|
||||
stderr: Some(output.stderr.text.clone()),
|
||||
aggregated_output: Some(output.aggregated_output.text.clone()),
|
||||
exit_code: Some(output.exit_code),
|
||||
duration: Some(output.duration),
|
||||
formatted_output: Some(format_exec_output_str(
|
||||
&output,
|
||||
turn_context.model_info.truncation_policy.into(),
|
||||
),
|
||||
status: if output.exit_code == 0 {
|
||||
ExecCommandStatus::Completed
|
||||
} else {
|
||||
ExecCommandStatus::Failed
|
||||
},
|
||||
)),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -329,28 +329,26 @@ pub(crate) async fn execute_user_shell_command(
|
||||
timed_out: false,
|
||||
};
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: call_id,
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
command: display_command,
|
||||
cwd: cwd.into(),
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: exec_output.stdout.text.clone(),
|
||||
stderr: exec_output.stderr.text.clone(),
|
||||
aggregated_output: exec_output.aggregated_output.text.clone(),
|
||||
exit_code: exec_output.exit_code,
|
||||
duration: exec_output.duration,
|
||||
formatted_output: format_exec_output_str(
|
||||
status: CommandExecutionStatus::Failed,
|
||||
stdout: Some(exec_output.stdout.text.clone()),
|
||||
stderr: Some(exec_output.stderr.text.clone()),
|
||||
aggregated_output: Some(exec_output.aggregated_output.text.clone()),
|
||||
exit_code: Some(exec_output.exit_code),
|
||||
duration: Some(exec_output.duration),
|
||||
formatted_output: Some(format_exec_output_str(
|
||||
&exec_output,
|
||||
turn_context.model_info.truncation_policy.into(),
|
||||
),
|
||||
status: ExecCommandStatus::Failed,
|
||||
)),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -56,6 +56,7 @@ use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
@@ -187,6 +188,7 @@ pub struct StartThreadOptions {
|
||||
pub config: Config,
|
||||
pub allow_provider_model_fallback: bool,
|
||||
pub initial_history: InitialHistory,
|
||||
pub history_mode: Option<ThreadHistoryMode>,
|
||||
pub session_source: Option<SessionSource>,
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
|
||||
@@ -647,6 +649,7 @@ impl ThreadManager {
|
||||
config,
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools,
|
||||
@@ -682,6 +685,7 @@ impl ThreadManager {
|
||||
Box::pin(self.state.spawn_thread_with_source(
|
||||
options.config,
|
||||
options.initial_history,
|
||||
options.history_mode,
|
||||
options.allow_provider_model_fallback,
|
||||
Arc::clone(&self.state.auth_manager),
|
||||
agent_control,
|
||||
@@ -778,6 +782,7 @@ impl ThreadManager {
|
||||
Box::pin(self.state.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
@@ -848,6 +853,7 @@ impl ThreadManager {
|
||||
Box::pin(self.state.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
@@ -1349,6 +1355,7 @@ impl ThreadManagerState {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
InitialHistory::New,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -1388,6 +1395,7 @@ impl ThreadManagerState {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -1429,6 +1437,7 @@ impl ThreadManagerState {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
@@ -1471,6 +1480,7 @@ impl ThreadManagerState {
|
||||
Box::pin(self.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
/*history_mode*/ None,
|
||||
/*allow_provider_model_fallback*/ false,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
@@ -1496,6 +1506,7 @@ impl ThreadManagerState {
|
||||
&self,
|
||||
config: Config,
|
||||
initial_history: InitialHistory,
|
||||
history_mode: Option<ThreadHistoryMode>,
|
||||
allow_provider_model_fallback: bool,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControl,
|
||||
@@ -1575,6 +1586,7 @@ impl ThreadManagerState {
|
||||
code_mode_session_provider: Arc::clone(&self.code_mode_session_provider),
|
||||
extensions: Arc::clone(&self.extensions),
|
||||
conversation_history: initial_history,
|
||||
requested_history_mode: history_mode,
|
||||
session_source,
|
||||
forked_from_thread_id,
|
||||
parent_thread_id,
|
||||
|
||||
@@ -472,6 +472,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
|
||||
config,
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: Some(SessionSource::Internal(
|
||||
InternalSessionSource::MemoryConsolidation,
|
||||
)),
|
||||
@@ -615,6 +616,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
|
||||
config: config.clone(),
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -631,6 +633,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
|
||||
config: config.clone(),
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -696,11 +699,11 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
};
|
||||
assert_eq!(
|
||||
selected_servers(&first_resolved),
|
||||
selected_servers(&first_resolved.config),
|
||||
std::collections::BTreeMap::from([("selected-a".to_string(), "env-a".to_string())])
|
||||
);
|
||||
assert_eq!(
|
||||
selected_servers(&second_resolved),
|
||||
selected_servers(&second_resolved.config),
|
||||
std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())])
|
||||
);
|
||||
}
|
||||
@@ -739,6 +742,7 @@ async fn selected_capability_roots_round_trip_through_fork() {
|
||||
git: None,
|
||||
},
|
||||
)]),
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -810,6 +814,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
||||
config: source_config,
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -1094,6 +1099,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
|
||||
config: config.clone(),
|
||||
allow_provider_model_fallback: false,
|
||||
initial_history: InitialHistory::New,
|
||||
history_mode: None,
|
||||
session_source: None,
|
||||
thread_source: Some(ThreadSource::User),
|
||||
dynamic_tools: Vec::new(),
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use crate::event_mapping;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -149,6 +153,58 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
items[..cut_idx].to_vec()
|
||||
}
|
||||
|
||||
/// Return a rollout prefix ending after the requested persisted terminal turn.
|
||||
///
|
||||
/// The turn must still be present in the effective post-rollback history and
|
||||
/// must have an explicit persisted TurnStarted boundary. Synthetic IDs
|
||||
/// generated while projecting legacy rollouts are intentionally unsupported
|
||||
/// because they do not provide a stable raw rollout boundary for a fork.
|
||||
pub fn truncate_rollout_after_turn_id(
|
||||
items: &[RolloutItem],
|
||||
last_turn_id: &str,
|
||||
) -> CodexResult<Vec<RolloutItem>> {
|
||||
let turns = build_turns_from_rollout_items(items);
|
||||
let turn = turns
|
||||
.iter()
|
||||
.find(|turn| turn.id == last_turn_id)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' was not found in the source thread"
|
||||
))
|
||||
})?;
|
||||
|
||||
let target_start_index = items
|
||||
.iter()
|
||||
.position(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(event))
|
||||
if event.turn_id == last_turn_id
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' is not a persisted canonical turn in the source thread"
|
||||
))
|
||||
})?;
|
||||
|
||||
if matches!(turn.status, TurnStatus::InProgress) {
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' identifies an in-progress turn"
|
||||
)));
|
||||
}
|
||||
|
||||
let cut_index = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(target_start_index.saturating_add(1))
|
||||
.find_map(|(index, item)| {
|
||||
matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))).then_some(index)
|
||||
})
|
||||
.unwrap_or(items.len());
|
||||
Ok(items[..cut_index].to_vec())
|
||||
}
|
||||
|
||||
/// Return a suffix of `items` that keeps the last `n_from_end` fork turns.
|
||||
///
|
||||
/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn
|
||||
|
||||
@@ -6,6 +6,9 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -66,6 +69,104 @@ fn inter_agent_communication(text: &str, trigger_turn: bool) -> RolloutItem {
|
||||
))
|
||||
}
|
||||
|
||||
fn turn_started(turn_id: &str) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn turn_completed(turn_id: &str) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_after_terminal_canonical_turn_id() {
|
||||
let rollout = vec![
|
||||
turn_started("turn-1"),
|
||||
turn_completed("turn-1"),
|
||||
turn_started("turn-2"),
|
||||
turn_completed("turn-2"),
|
||||
turn_started("turn-3"),
|
||||
turn_completed("turn-3"),
|
||||
];
|
||||
|
||||
let truncated =
|
||||
truncate_rollout_after_turn_id(&rollout, "turn-2").expect("truncate through turn-2");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&truncated).unwrap(),
|
||||
serde_json::to_value(&rollout[..4]).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_rolled_back_turn() {
|
||||
let rollout = vec![
|
||||
turn_started("turn-1"),
|
||||
turn_completed("turn-1"),
|
||||
turn_started("turn-2"),
|
||||
turn_completed("turn-2"),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
})),
|
||||
turn_started("turn-3"),
|
||||
turn_completed("turn-3"),
|
||||
];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "turn-2")
|
||||
.expect_err("rolled-back turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message == "lastTurnId 'turn-2' was not found in the source thread"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_synthetic_legacy_turn_id() {
|
||||
let rollout = vec![RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
UserMessageEvent {
|
||||
message: "legacy".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
))];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "rollout-0")
|
||||
.expect_err("synthetic turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message
|
||||
== "lastTurnId 'rollout-0' is not a persisted canonical turn in the source thread"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_in_progress_turn() {
|
||||
let rollout = vec![turn_started("turn-1")];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "turn-1")
|
||||
.expect_err("in-progress turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message == "lastTurnId 'turn-1' identifies an in-progress turn"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_from_start_before_nth_user_only() {
|
||||
let items = [
|
||||
|
||||
@@ -85,6 +85,7 @@ impl CodeModeExecuteHandler {
|
||||
.code_mode_service
|
||||
.finish_cell_dispatch(&cell_id);
|
||||
}
|
||||
exec.session.services.elicitations.wait_until_clear().await;
|
||||
handle_runtime_response(&exec, response, args.max_output_tokens, started_at)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)
|
||||
|
||||
@@ -120,6 +120,7 @@ impl CodeModeWaitHandler {
|
||||
.code_mode_service
|
||||
.finish_cell_dispatch(runtime_cell_id);
|
||||
}
|
||||
exec.session.services.elicitations.wait_until_clear().await;
|
||||
handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at)
|
||||
.await
|
||||
.map(boxed_tool_output)
|
||||
|
||||
@@ -8,6 +8,8 @@ use codex_apply_patch::AppliedPatchDelta;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::items::CommandExecutionItem;
|
||||
use codex_protocol::items::CommandExecutionStatus;
|
||||
use codex_protocol::items::FileChangeItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -102,19 +104,43 @@ pub(crate) async fn emit_exec_command_begin(
|
||||
interaction_input: Option<String>,
|
||||
process_id: Option<&str>,
|
||||
) {
|
||||
if matches!(source, ExecCommandSource::UnifiedExecInteraction) {
|
||||
ctx.session
|
||||
.send_event(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
process_id: process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
command: command.to_vec(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.to_vec(),
|
||||
source,
|
||||
interaction_input,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
ctx.session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
&TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
process_id: process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
command: command.to_vec(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.to_vec(),
|
||||
source,
|
||||
interaction_input,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration: None,
|
||||
formatted_output: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -542,26 +568,50 @@ async fn emit_exec_end(
|
||||
exec_input: ExecCommandInput<'_>,
|
||||
exec_result: ExecCommandResult,
|
||||
) {
|
||||
if matches!(exec_input.source, ExecCommandSource::UnifiedExecInteraction) {
|
||||
ctx.session
|
||||
.send_event(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
process_id: exec_input.process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.clone(),
|
||||
parsed_cmd: exec_input.parsed_cmd.to_vec(),
|
||||
source: exec_input.source,
|
||||
interaction_input: exec_input.interaction_input.map(str::to_owned),
|
||||
stdout: exec_result.stdout,
|
||||
stderr: exec_result.stderr,
|
||||
aggregated_output: exec_result.aggregated_output,
|
||||
exit_code: exec_result.exit_code,
|
||||
duration: exec_result.duration,
|
||||
formatted_output: exec_result.formatted_output,
|
||||
status: exec_result.status,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
ctx.session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
TurnItem::CommandExecution(CommandExecutionItem {
|
||||
id: ctx.call_id.to_string(),
|
||||
process_id: exec_input.process_id.map(str::to_owned),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.clone(),
|
||||
parsed_cmd: exec_input.parsed_cmd.to_vec(),
|
||||
source: exec_input.source,
|
||||
interaction_input: exec_input.interaction_input.map(str::to_owned),
|
||||
stdout: exec_result.stdout,
|
||||
stderr: exec_result.stderr,
|
||||
aggregated_output: exec_result.aggregated_output,
|
||||
exit_code: exec_result.exit_code,
|
||||
duration: exec_result.duration,
|
||||
formatted_output: exec_result.formatted_output,
|
||||
status: exec_result.status,
|
||||
status: exec_result.status.into(),
|
||||
stdout: Some(exec_result.stdout),
|
||||
stderr: Some(exec_result.stderr),
|
||||
aggregated_output: Some(exec_result.aggregated_output),
|
||||
exit_code: Some(exec_result.exit_code),
|
||||
duration: Some(exec_result.duration),
|
||||
formatted_output: Some(exec_result.formatted_output),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -203,7 +203,7 @@ async fn run_agent_job_loop(
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
options.spawn_config.clone(),
|
||||
items.into(),
|
||||
items,
|
||||
Some(SessionSource::SubAgent(SubAgentSource::Other(format!(
|
||||
"agent_job:{job_id}"
|
||||
)))),
|
||||
|
||||
@@ -9,14 +9,13 @@ use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use crate::tools::registry::ToolExposure;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::items::DynamicToolCallItem;
|
||||
use codex_protocol::items::DynamicToolCallStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::protocol::DynamicToolCallResponseEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
@@ -178,7 +177,6 @@ async fn request_dynamic_tool(
|
||||
) -> Option<DynamicToolResponse> {
|
||||
let namespace = tool_name.namespace;
|
||||
let tool = tool_name.name;
|
||||
let turn_id = turn_context.sub_id.clone();
|
||||
let (tx_response, rx_response) = oneshot::channel();
|
||||
let event_id = call_id.clone();
|
||||
let prev_entry = {
|
||||
@@ -196,45 +194,55 @@ async fn request_dynamic_tool(
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
let started_at_ms = now_unix_timestamp_ms();
|
||||
let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
|
||||
call_id: call_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
started_at_ms,
|
||||
namespace: namespace.clone(),
|
||||
tool: tool.clone(),
|
||||
arguments: arguments.clone(),
|
||||
});
|
||||
session.send_event(turn_context, event).await;
|
||||
session
|
||||
.emit_turn_item_started(
|
||||
turn_context,
|
||||
&TurnItem::DynamicToolCall(DynamicToolCallItem {
|
||||
id: call_id.clone(),
|
||||
namespace: namespace.clone(),
|
||||
tool: tool.clone(),
|
||||
arguments: arguments.clone(),
|
||||
status: DynamicToolCallStatus::InProgress,
|
||||
content_items: None,
|
||||
success: None,
|
||||
error: None,
|
||||
duration: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let response = rx_response.await.ok();
|
||||
|
||||
let response_event = match &response {
|
||||
Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
let item = match &response {
|
||||
Some(response) => DynamicToolCallItem {
|
||||
id: call_id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
content_items: response.content_items.clone(),
|
||||
success: response.success,
|
||||
status: if response.success {
|
||||
DynamicToolCallStatus::Completed
|
||||
} else {
|
||||
DynamicToolCallStatus::Failed
|
||||
},
|
||||
content_items: Some(response.content_items.clone()),
|
||||
success: Some(response.success),
|
||||
error: None,
|
||||
duration: started_at.elapsed(),
|
||||
}),
|
||||
None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
duration: Some(started_at.elapsed()),
|
||||
},
|
||||
None => DynamicToolCallItem {
|
||||
id: call_id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
content_items: Vec::new(),
|
||||
success: false,
|
||||
status: DynamicToolCallStatus::Failed,
|
||||
content_items: Some(Vec::new()),
|
||||
success: Some(false),
|
||||
error: Some("dynamic tool call was cancelled before receiving a response".to_string()),
|
||||
duration: started_at.elapsed(),
|
||||
}),
|
||||
duration: Some(started_at.elapsed()),
|
||||
},
|
||||
};
|
||||
session.send_event(turn_context, response_event).await;
|
||||
session
|
||||
.emit_turn_item_completed(turn_context, TurnItem::DynamicToolCall(item))
|
||||
.await;
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_tools::ConversationHistory;
|
||||
use codex_tools::ExtensionTurnItem;
|
||||
use codex_tools::ToolCall as ExtensionToolCall;
|
||||
@@ -16,7 +18,6 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::stream_events_utils::TurnItemContributorPolicy;
|
||||
use crate::stream_events_utils::apply_turn_item_contributors;
|
||||
use crate::stream_events_utils::finalize_turn_item;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -69,10 +70,14 @@ struct CoreTurnItemEmitter {
|
||||
turn: Weak<TurnContext>,
|
||||
}
|
||||
|
||||
fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem {
|
||||
match item {
|
||||
ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item),
|
||||
ExtensionTurnItem::ImageGeneration(item) => TurnItem::ImageGeneration(item),
|
||||
async fn emit_legacy_events(session: &Session, turn: &TurnContext, legacy_events: Vec<EventMsg>) {
|
||||
for msg in legacy_events {
|
||||
session
|
||||
.send_event_raw(Event {
|
||||
id: turn.sub_id.clone(),
|
||||
msg,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +87,15 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
session
|
||||
.emit_turn_item_started(turn.as_ref(), &extension_turn_item(item))
|
||||
.await;
|
||||
let (item, legacy_events) = match item {
|
||||
ExtensionTurnItem::WebSearch(item) => (TurnItem::WebSearch(item), Vec::new()),
|
||||
ExtensionTurnItem::Extension {
|
||||
item,
|
||||
legacy_events,
|
||||
} => (TurnItem::Extension(item), legacy_events),
|
||||
};
|
||||
session.emit_turn_item_started(turn.as_ref(), &item).await;
|
||||
emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,17 +104,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
let item = match item {
|
||||
ExtensionTurnItem::ImageGeneration(item) => {
|
||||
let mut item = TurnItem::ImageGeneration(item);
|
||||
apply_turn_item_contributors(
|
||||
session.as_ref(),
|
||||
turn.extension_data.as_ref(),
|
||||
&mut item,
|
||||
)
|
||||
.await;
|
||||
item
|
||||
}
|
||||
let (item, legacy_events) = match item {
|
||||
ExtensionTurnItem::Extension {
|
||||
item,
|
||||
legacy_events,
|
||||
} => (TurnItem::Extension(item), legacy_events),
|
||||
ExtensionTurnItem::WebSearch(item) => {
|
||||
let mut item = TurnItem::WebSearch(item);
|
||||
finalize_turn_item(
|
||||
@@ -115,10 +120,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
== codex_protocol::config_types::ModeKind::Plan,
|
||||
)
|
||||
.await;
|
||||
item
|
||||
(item, Vec::new())
|
||||
}
|
||||
};
|
||||
session.emit_turn_item_completed(turn.as_ref(), item).await;
|
||||
emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -173,14 +179,16 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::TurnItemContributor;
|
||||
use codex_extension_items::ExtensionItem;
|
||||
use codex_extension_items::image_generation::ImageGenerationItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ImageGenerationBeginEvent;
|
||||
use codex_protocol::protocol::ImageGenerationEndEvent;
|
||||
use codex_tools::ExtensionTurnItem;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
@@ -447,147 +455,64 @@ mod tests {
|
||||
assert_eq!(end.action, expected.action);
|
||||
}
|
||||
|
||||
struct ImageGenerationExtensionExecutor;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExtensionTurnItemContributorRan;
|
||||
|
||||
struct RecordExtensionTurnItemContributor;
|
||||
|
||||
impl TurnItemContributor for RecordExtensionTurnItemContributor {
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_thread_store: &'a ExtensionData,
|
||||
turn_store: &'a ExtensionData,
|
||||
_item: &'a mut TurnItem,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
|
||||
Box::pin(async move {
|
||||
turn_store.insert(ExtensionTurnItemContributorRan);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_completion_runs_turn_item_contributors() {
|
||||
let (mut session, turn) = crate::session::tests::make_session_and_context().await;
|
||||
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor));
|
||||
session.services.extensions = Arc::new(builder.build());
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let emitter = CoreTurnItemEmitter {
|
||||
session: Arc::downgrade(&session),
|
||||
turn: Arc::downgrade(&turn),
|
||||
};
|
||||
|
||||
codex_tools::TurnItemEmitter::emit_completed(
|
||||
&emitter,
|
||||
ExtensionTurnItem::WebSearch(WebSearchItem {
|
||||
id: "search-1".to_string(),
|
||||
query: "contributors".to_string(),
|
||||
action: WebSearchAction::Other,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
turn.extension_data
|
||||
.get::<ExtensionTurnItemContributorRan>()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {
|
||||
fn tool_name(&self) -> codex_tools::ToolName {
|
||||
codex_tools::ToolName::namespaced("image_gen", "imagegen")
|
||||
}
|
||||
|
||||
fn spec(&self) -> codex_tools::ToolSpec {
|
||||
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
|
||||
name: "imagegen".to_string(),
|
||||
description: "Generates an image.".to_string(),
|
||||
strict: false,
|
||||
parameters: codex_tools::JsonSchema::default(),
|
||||
output_schema: None,
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(call))
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageGenerationExtensionExecutor {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
call.turn_item_emitter
|
||||
.emit_started(ExtensionTurnItem::ImageGeneration(
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: call.call_id.clone(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
call.turn_item_emitter
|
||||
.emit_completed(ExtensionTurnItem::ImageGeneration(
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: call.call_id,
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
Ok(
|
||||
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
|
||||
as Box<dyn codex_tools::ToolOutput>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_generation_publication_preserves_extension_saved_path() {
|
||||
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor));
|
||||
let expected_path = test_path_buf("/tmp/extension-claimed.png").abs();
|
||||
let default_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn.config.codex_home,
|
||||
&session.thread_id.to_string(),
|
||||
"call-image",
|
||||
);
|
||||
let step_context = StepContext::for_test(Arc::clone(&turn));
|
||||
let invocation = ToolInvocation {
|
||||
session,
|
||||
step_context,
|
||||
turn,
|
||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-image".to_string(),
|
||||
tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
let emitter = CoreTurnItemEmitter {
|
||||
session: Arc::downgrade(&session),
|
||||
turn: Arc::downgrade(&turn),
|
||||
};
|
||||
|
||||
crate::tools::registry::ToolExecutor::handle(&handler, invocation)
|
||||
.await
|
||||
.expect("extension call should succeed");
|
||||
let expected_started_item = ExtensionItem::ImageGeneration(ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
});
|
||||
let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(expected_path.clone()),
|
||||
});
|
||||
codex_tools::TurnItemEmitter::emit_started(
|
||||
&emitter,
|
||||
ExtensionTurnItem::Extension {
|
||||
item: expected_started_item.clone(),
|
||||
legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent {
|
||||
call_id: "call-image".to_string(),
|
||||
})],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
codex_tools::TurnItemEmitter::emit_completed(
|
||||
&emitter,
|
||||
ExtensionTurnItem::Extension {
|
||||
item: expected_completed_item.clone(),
|
||||
legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
||||
call_id: "call-image".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(expected_path.clone()),
|
||||
})],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let started = rx.recv().await.expect("item started event");
|
||||
let EventMsg::ItemStarted(started) = started.msg else {
|
||||
panic!("expected item started event");
|
||||
};
|
||||
let TurnItem::ImageGeneration(started_item) = started.item else {
|
||||
panic!("expected image generation item");
|
||||
let TurnItem::Extension(started_item) = started.item else {
|
||||
panic!("expected extension item");
|
||||
};
|
||||
let begin = rx.recv().await.expect("legacy image start event");
|
||||
assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_)));
|
||||
@@ -595,32 +520,14 @@ mod tests {
|
||||
let EventMsg::ItemCompleted(completed) = completed.msg else {
|
||||
panic!("expected item completed event");
|
||||
};
|
||||
let TurnItem::ImageGeneration(completed_item) = completed.item else {
|
||||
panic!("expected image generation item");
|
||||
let TurnItem::Extension(completed_item) = completed.item else {
|
||||
panic!("expected extension item");
|
||||
};
|
||||
let end = rx.recv().await.expect("legacy image end event");
|
||||
assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_)));
|
||||
|
||||
assert_eq!(
|
||||
started_item,
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
completed_item,
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(expected_path.clone()),
|
||||
}
|
||||
);
|
||||
assert_eq!(started_item, expected_started_item);
|
||||
assert_eq!(completed_item, expected_completed_item);
|
||||
assert!(!default_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -20,19 +18,13 @@ use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::CollabAgentTool;
|
||||
use codex_protocol::items::CollabAgentToolCallItem;
|
||||
use codex_protocol::items::CollabAgentToolCallStatus;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
|
||||
use codex_protocol::protocol::CollabCloseBeginEvent;
|
||||
use codex_protocol::protocol::CollabCloseEndEvent;
|
||||
use codex_protocol::protocol::CollabResumeBeginEvent;
|
||||
use codex_protocol::protocol::CollabResumeEndEvent;
|
||||
use codex_protocol::protocol::CollabWaitingBeginEvent;
|
||||
use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSearchInfo;
|
||||
@@ -91,6 +83,17 @@ mod send_input;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
pub(crate) fn collab_tool_call_status(
|
||||
status: &AgentStatus,
|
||||
receiver_thread_id: Option<ThreadId>,
|
||||
) -> CollabAgentToolCallStatus {
|
||||
match status {
|
||||
AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed,
|
||||
_ if receiver_thread_id.is_some() => CollabAgentToolCallStatus::Completed,
|
||||
_ => CollabAgentToolCallStatus::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "multi_agents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::*;
|
||||
use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
@@ -44,15 +43,20 @@ async fn handle_close_agent(
|
||||
let known_agent = receiver_agent.is_some();
|
||||
let receiver_agent = receiver_agent.unwrap_or_default();
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabCloseBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id: agent_id,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![agent_id],
|
||||
receiver_agents: Vec::new(),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let status = match session
|
||||
@@ -68,18 +72,24 @@ async fn handle_close_agent(
|
||||
Err(err) => {
|
||||
let status = session.services.agent_control.get_status(agent_id).await;
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabCloseEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status: collab_tool_call_status(&status, Some(agent_id)),
|
||||
sender_thread_id: session.thread_id(),
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
receiver_agent_role: receiver_agent.agent_role.clone(),
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![agent_id],
|
||||
receiver_agents: vec![CollabAgentRef {
|
||||
thread_id: agent_id,
|
||||
agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
agent_role: receiver_agent.agent_role.clone(),
|
||||
}],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(agent_id, status)].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return Err(collab_agent_error(agent_id, err));
|
||||
@@ -90,18 +100,24 @@ async fn handle_close_agent(
|
||||
.map_err(|err| collab_agent_error(agent_id, err))
|
||||
.map(|_| ());
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabCloseEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status: collab_tool_call_status(&status, Some(agent_id)),
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
status: status.clone(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![agent_id],
|
||||
receiver_agents: vec![CollabAgentRef {
|
||||
thread_id: agent_id,
|
||||
agent_nickname: receiver_agent.agent_nickname,
|
||||
agent_role: receiver_agent.agent_role,
|
||||
}],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(agent_id, status.clone())].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
result?;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::*;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -57,17 +58,24 @@ async fn handle_resume_agent(
|
||||
}
|
||||
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabResumeBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
receiver_agent_role: receiver_agent.agent_role.clone(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![receiver_thread_id],
|
||||
receiver_agents: vec![CollabAgentRef {
|
||||
thread_id: receiver_thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
agent_role: receiver_agent.agent_role.clone(),
|
||||
}],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -113,18 +121,24 @@ async fn handle_resume_agent(
|
||||
(receiver_agent, None)
|
||||
};
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabResumeEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status: collab_tool_call_status(&status, Some(receiver_thread_id)),
|
||||
sender_thread_id: session.thread_id(),
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
status: status.clone(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![receiver_thread_id],
|
||||
receiver_agents: vec![CollabAgentRef {
|
||||
thread_id: receiver_thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname,
|
||||
agent_role: receiver_agent.agent_role,
|
||||
}],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(receiver_thread_id, status.clone())].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::*;
|
||||
use crate::agent::control::render_input_preview;
|
||||
use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
@@ -67,16 +66,20 @@ impl Handler {
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabAgentInteractionBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id,
|
||||
prompt: prompt.clone(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![receiver_thread_id],
|
||||
receiver_agents: Vec::new(),
|
||||
prompt: Some(prompt.clone()),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let agent_control = session.services.agent_control.clone();
|
||||
@@ -90,19 +93,24 @@ impl Handler {
|
||||
.get_status(receiver_thread_id)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabAgentInteractionEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status: collab_tool_call_status(&status, Some(receiver_thread_id)),
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
prompt,
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: vec![receiver_thread_id],
|
||||
receiver_agents: vec![CollabAgentRef {
|
||||
thread_id: receiver_thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname,
|
||||
agent_role: receiver_agent.agent_role,
|
||||
}],
|
||||
prompt: Some(prompt),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(receiver_thread_id, status)].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let submission_id = result?;
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
|
||||
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -71,17 +70,20 @@ async fn handle_spawn_agent(
|
||||
));
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabAgentSpawnBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
prompt: prompt.clone(),
|
||||
model: args.model.clone().unwrap_or_default(),
|
||||
reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: Vec::new(),
|
||||
receiver_agents: Vec::new(),
|
||||
prompt: Some(prompt.clone()),
|
||||
model: Some(args.model.clone().unwrap_or_default()),
|
||||
reasoning_effort: Some(args.reasoning_effort.clone().unwrap_or_default()),
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let mut config =
|
||||
@@ -177,22 +179,33 @@ async fn handle_spawn_agent(
|
||||
.and_then(|snapshot| snapshot.reasoning_effort.clone())
|
||||
.unwrap_or(args.reasoning_effort.unwrap_or_default());
|
||||
let nickname = new_agent_nickname.clone();
|
||||
let receiver_thread_ids = new_thread_id.into_iter().collect();
|
||||
let receiver_agents = new_thread_id
|
||||
.map(|thread_id| CollabAgentRef {
|
||||
thread_id,
|
||||
agent_nickname: new_agent_nickname,
|
||||
agent_role: new_agent_role,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
let agents_states = new_thread_id
|
||||
.map(|thread_id| [(thread_id, status.clone())].into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabAgentSpawnEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status: collab_tool_call_status(&status, new_thread_id),
|
||||
sender_thread_id: session.thread_id,
|
||||
new_thread_id,
|
||||
new_agent_nickname,
|
||||
new_agent_role,
|
||||
prompt,
|
||||
model: effective_model,
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids,
|
||||
receiver_agents,
|
||||
prompt: Some(prompt),
|
||||
model: Some(effective_model),
|
||||
reasoning_effort: Some(effective_reasoning_effort),
|
||||
agents_states,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let new_thread_id = result?.thread_id;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::*;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::session::session::Session;
|
||||
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
|
||||
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::FutureExt;
|
||||
@@ -97,16 +97,20 @@ impl Handler {
|
||||
};
|
||||
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabWaitingBeginEvent {
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_ids: receiver_thread_ids.clone(),
|
||||
receiver_agents: receiver_agents.clone(),
|
||||
call_id: call_id.clone(),
|
||||
}
|
||||
.into(),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -128,19 +132,20 @@ impl Handler {
|
||||
let mut statuses = HashMap::with_capacity(1);
|
||||
statuses.insert(*id, session.services.agent_control.get_status(*id).await);
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabWaitingEndEvent {
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: wait_tool_call_status(&statuses),
|
||||
sender_thread_id: session.thread_id,
|
||||
call_id: call_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
agent_statuses: build_wait_agent_statuses(
|
||||
&statuses,
|
||||
&receiver_agents,
|
||||
),
|
||||
statuses,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: statuses.keys().copied().collect(),
|
||||
receiver_agents: wait_receiver_agents(&statuses, &receiver_agents),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: statuses,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return Err(collab_agent_error(*id, err));
|
||||
@@ -182,7 +187,6 @@ impl Handler {
|
||||
|
||||
let timed_out = statuses.is_empty();
|
||||
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
|
||||
let result = WaitAgentResult {
|
||||
status: statuses
|
||||
.into_iter()
|
||||
@@ -197,16 +201,20 @@ impl Handler {
|
||||
};
|
||||
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabWaitingEndEvent {
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: wait_tool_call_status(&statuses_by_id),
|
||||
sender_thread_id: session.thread_id,
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
agent_statuses,
|
||||
statuses: statuses_by_id,
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: statuses_by_id.keys().copied().collect(),
|
||||
receiver_agents: wait_receiver_agents(&statuses_by_id, &receiver_agents),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: statuses_by_id,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -214,6 +222,48 @@ impl Handler {
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_tool_call_status(statuses: &HashMap<ThreadId, AgentStatus>) -> CollabAgentToolCallStatus {
|
||||
if statuses
|
||||
.values()
|
||||
.any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound))
|
||||
{
|
||||
CollabAgentToolCallStatus::Failed
|
||||
} else {
|
||||
CollabAgentToolCallStatus::Completed
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_receiver_agents(
|
||||
statuses: &HashMap<ThreadId, AgentStatus>,
|
||||
receiver_agents: &[CollabAgentRef],
|
||||
) -> Vec<CollabAgentRef> {
|
||||
if statuses.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut agents = Vec::with_capacity(statuses.len());
|
||||
let mut seen = HashMap::with_capacity(receiver_agents.len());
|
||||
for receiver_agent in receiver_agents {
|
||||
seen.insert(receiver_agent.thread_id, ());
|
||||
if statuses.contains_key(&receiver_agent.thread_id) {
|
||||
agents.push(receiver_agent.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras = statuses
|
||||
.keys()
|
||||
.filter(|thread_id| !seen.contains_key(thread_id))
|
||||
.map(|thread_id| CollabAgentRef {
|
||||
thread_id: *thread_id,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
extras.sort_by_key(|agent| agent.thread_id.to_string());
|
||||
agents.extend(extras);
|
||||
agents
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for Handler {
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::config::Config;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
|
||||
@@ -16,15 +15,11 @@ use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentStatusEntry;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
@@ -72,43 +67,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_wait_agent_statuses(
|
||||
statuses: &HashMap<ThreadId, AgentStatus>,
|
||||
receiver_agents: &[CollabAgentRef],
|
||||
) -> Vec<CollabAgentStatusEntry> {
|
||||
if statuses.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(statuses.len());
|
||||
let mut seen = HashMap::with_capacity(receiver_agents.len());
|
||||
for receiver_agent in receiver_agents {
|
||||
seen.insert(receiver_agent.thread_id, ());
|
||||
if let Some(status) = statuses.get(&receiver_agent.thread_id) {
|
||||
entries.push(CollabAgentStatusEntry {
|
||||
thread_id: receiver_agent.thread_id,
|
||||
agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
agent_role: receiver_agent.agent_role.clone(),
|
||||
status: status.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut extras = statuses
|
||||
.iter()
|
||||
.filter(|(thread_id, _)| !seen.contains_key(thread_id))
|
||||
.map(|(thread_id, status)| CollabAgentStatusEntry {
|
||||
thread_id: *thread_id,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
status: status.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
extras.sort_by_key(|entry| entry.thread_id.to_string());
|
||||
entries.extend(extras);
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError {
|
||||
match err {
|
||||
CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => {
|
||||
@@ -163,7 +121,7 @@ pub(crate) fn thread_spawn_source(
|
||||
pub(crate) fn parse_collab_input(
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
) -> Result<Op, FunctionCallError> {
|
||||
) -> Result<Vec<UserInput>, FunctionCallError> {
|
||||
match (message, items) {
|
||||
(Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
|
||||
"Provide either message or items, but not both".to_string(),
|
||||
@@ -180,8 +138,7 @@ pub(crate) fn parse_collab_input(
|
||||
Ok(vec![UserInput::Text {
|
||||
text: message,
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into())
|
||||
}])
|
||||
}
|
||||
(None, Some(items)) => {
|
||||
if items.is_empty() {
|
||||
@@ -189,7 +146,7 @@ pub(crate) fn parse_collab_input(
|
||||
"Items can't be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(items.into())
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,9 +669,16 @@ fn spawn_agent_tool_description(
|
||||
{tool_description}
|
||||
This spawn_agent tool provides you access to sub-agents that inherit your current model by default. Do not set the `model` field unless the user explicitly asks for a different model or there is a clear task-specific reason. You should follow the rules and guidelines below to use this tool.
|
||||
|
||||
Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work.
|
||||
Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.
|
||||
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
|
||||
{agent_role_usage_hint}
|
||||
|
||||
### When to delegate vs. do the subtask yourself
|
||||
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
|
||||
- Use a subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step.
|
||||
- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving.
|
||||
- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step.
|
||||
|
||||
### Designing delegated subtasks
|
||||
- Subtasks must be concrete, well-defined, and self-contained.
|
||||
- Delegated subtasks must materially advance the main task.
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::init_state_db;
|
||||
use crate::local_agent_graph_store_from_state_db;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session_prefix::format_inter_agent_completion_message;
|
||||
use crate::thread_manager::thread_store_from_config;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -1376,8 +1377,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1453,8 +1453,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1618,8 +1617,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
vec![UserInput::Text {
|
||||
text: "research".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -1639,8 +1637,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
vec![UserInput::Text {
|
||||
text: "build".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 2,
|
||||
@@ -4080,8 +4077,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_id() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
@@ -4148,8 +4144,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_task_name() {
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
|
||||
@@ -12,14 +12,15 @@ use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::items::CollabAgentTool;
|
||||
use codex_protocol::items::CollabAgentToolCallItem;
|
||||
use codex_protocol::items::CollabAgentToolCallStatus;
|
||||
use codex_protocol::items::SubAgentActivityItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::CollabWaitingBeginEvent;
|
||||
use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SubAgentActivityEvent;
|
||||
use codex_protocol::protocol::SubAgentActivityKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolName;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -40,6 +41,16 @@ mod send_message;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
pub(crate) async fn emit_sub_agent_activity(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
item: SubAgentActivityItem,
|
||||
) {
|
||||
session
|
||||
.emit_turn_item_completed(turn, TurnItem::SubAgentActivity(item))
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) fn communication_from_tool_message(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::*;
|
||||
use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
@@ -71,19 +70,17 @@ async fn handle_interrupt_agent(
|
||||
Err(err) => Err(collab_agent_error(agent_id, err)),
|
||||
};
|
||||
result?;
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
SubAgentActivityEvent {
|
||||
event_id: call_id,
|
||||
occurred_at_ms: now_unix_timestamp_ms(),
|
||||
agent_thread_id: agent_id,
|
||||
agent_path: receiver_agent_path,
|
||||
kind: SubAgentActivityKind::Interrupted,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
emit_sub_agent_activity(
|
||||
&session,
|
||||
&turn,
|
||||
SubAgentActivityItem {
|
||||
id: call_id,
|
||||
agent_thread_id: agent_id,
|
||||
agent_path: receiver_agent_path,
|
||||
kind: SubAgentActivityKind::Interrupted,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(InterruptAgentResult {
|
||||
previous_status: status,
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! resulting `InterAgentCommunication` should wake the target immediately.
|
||||
|
||||
use super::*;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -46,7 +47,7 @@ pub(crate) struct FollowupTaskArgs {
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
fn message_content(message: String) -> Result<String, FunctionCallError> {
|
||||
pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> {
|
||||
if message.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
@@ -101,26 +102,29 @@ pub(crate) async fn handle_message_string_tool(
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication =
|
||||
communication_from_tool_message(author, receiver_agent_path.clone(), message);
|
||||
let kind = match mode {
|
||||
MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message,
|
||||
MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup,
|
||||
};
|
||||
let context = AgentCommunicationContext::new(kind, session.thread_id);
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication))
|
||||
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
result?;
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
SubAgentActivityEvent {
|
||||
event_id: call_id,
|
||||
occurred_at_ms: now_unix_timestamp_ms(),
|
||||
agent_thread_id: receiver_thread_id,
|
||||
agent_path: receiver_agent_path,
|
||||
kind: SubAgentActivityKind::Interacted,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
emit_sub_agent_activity(
|
||||
&session,
|
||||
&turn,
|
||||
SubAgentActivityItem {
|
||||
id: call_id,
|
||||
agent_thread_id: receiver_thread_id,
|
||||
agent_path: receiver_agent_path,
|
||||
kind: SubAgentActivityKind::Interacted,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(FunctionToolOutput::from_text(String::new(), Some(true)))
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
|
||||
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use crate::tools::handlers::multi_agents_v2::message_tool::message_content;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -55,8 +56,7 @@ async fn handle_spawn_agent(
|
||||
.map(str::trim)
|
||||
.filter(|role| !role.is_empty());
|
||||
|
||||
let message = args.message.clone();
|
||||
let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?;
|
||||
let message = message_content(args.message)?;
|
||||
let session_source = turn.session_source.clone();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let mut config =
|
||||
@@ -104,33 +104,28 @@ async fn handle_spawn_agent(
|
||||
"spawned agent is missing a canonical task name".to_string(),
|
||||
)
|
||||
})?;
|
||||
let author = turn
|
||||
.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication = communication_from_tool_message(author, new_agent_path.clone(), message);
|
||||
let context = AgentCommunicationContext::new(AgentCommunicationKind::Spawn, session.thread_id);
|
||||
let spawned_agent = Box::pin(
|
||||
session.services.agent_control.spawn_agent_with_metadata(
|
||||
config,
|
||||
match initial_operation {
|
||||
Op::UserInput { items, .. }
|
||||
if items
|
||||
.iter()
|
||||
.all(|item| matches!(item, UserInput::Text { .. })) =>
|
||||
{
|
||||
let author = turn
|
||||
.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let communication =
|
||||
communication_from_tool_message(author, new_agent_path.clone(), message);
|
||||
Op::InterAgentCommunication { communication }
|
||||
}
|
||||
initial_operation => initial_operation,
|
||||
},
|
||||
Some(spawn_source),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
|
||||
fork_mode,
|
||||
parent_thread_id: Some(session.thread_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
),
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_communication(
|
||||
config,
|
||||
communication,
|
||||
context,
|
||||
Some(spawn_source),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
|
||||
fork_mode,
|
||||
parent_thread_id: Some(session.thread_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(collab_spawn_error)?;
|
||||
@@ -144,19 +139,17 @@ async fn handle_spawn_agent(
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.session_source.get_nickname())
|
||||
.or(spawned_agent.metadata.agent_nickname);
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
SubAgentActivityEvent {
|
||||
event_id: call_id,
|
||||
occurred_at_ms: now_unix_timestamp_ms(),
|
||||
agent_thread_id: new_thread_id,
|
||||
agent_path: new_agent_path.clone(),
|
||||
kind: SubAgentActivityKind::Started,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
emit_sub_agent_activity(
|
||||
&session,
|
||||
&turn,
|
||||
SubAgentActivityItem {
|
||||
id: call_id,
|
||||
agent_thread_id: new_thread_id,
|
||||
agent_path: new_agent_path.clone(),
|
||||
kind: SubAgentActivityKind::Started,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
turn.session_telemetry.counter(
|
||||
"codex.multi_agent.spawn",
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::*;
|
||||
use crate::session::InputQueueActivity;
|
||||
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
|
||||
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
@@ -76,16 +75,20 @@ impl Handler {
|
||||
.await;
|
||||
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_started(
|
||||
&turn,
|
||||
CollabWaitingBeginEvent {
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
&TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id.clone(),
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_ids: Vec::new(),
|
||||
receiver_agents: Vec::new(),
|
||||
call_id: call_id.clone(),
|
||||
}
|
||||
.into(),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -94,16 +97,20 @@ impl Handler {
|
||||
let result = WaitAgentResult::from_outcome(outcome);
|
||||
|
||||
session
|
||||
.send_event(
|
||||
.emit_turn_item_completed(
|
||||
&turn,
|
||||
CollabWaitingEndEvent {
|
||||
TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
|
||||
id: call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: CollabAgentToolCallStatus::Completed,
|
||||
sender_thread_id: session.thread_id,
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
agent_statuses: Vec::new(),
|
||||
statuses: HashMap::new(),
|
||||
}
|
||||
.into(),
|
||||
receiver_thread_ids: Vec::new(),
|
||||
receiver_agents: Vec::new(),
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec {
|
||||
}
|
||||
|
||||
pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolSpec> {
|
||||
let (external_web_access, index_gated_web_access) = match options.web_search_mode {
|
||||
let (external_web_access, indexed_web_access) = match options.web_search_mode {
|
||||
Some(WebSearchMode::Cached) => (false, None),
|
||||
Some(WebSearchMode::Indexed) => (true, Some(true)),
|
||||
Some(WebSearchMode::Live) => (true, None),
|
||||
@@ -37,7 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolS
|
||||
|
||||
Some(ToolSpec::WebSearch {
|
||||
external_web_access: Some(external_web_access),
|
||||
index_gated_web_access,
|
||||
indexed_web_access,
|
||||
filters: options
|
||||
.web_search_config
|
||||
.and_then(|config| config.filters.clone().map(Into::into)),
|
||||
|
||||
@@ -39,7 +39,7 @@ fn web_search_tool_preserves_configured_options() {
|
||||
}),
|
||||
Some(ToolSpec::WebSearch {
|
||||
external_web_access: Some(true),
|
||||
index_gated_web_access: None,
|
||||
indexed_web_access: None,
|
||||
filters: Some(ResponsesApiWebSearchFilters {
|
||||
allowed_domains: Some(vec!["example.com".to_string()]),
|
||||
}),
|
||||
|
||||
@@ -27,9 +27,11 @@ use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
@@ -59,6 +61,7 @@ pub(crate) struct DeferredNetworkApproval {
|
||||
registration_id: String,
|
||||
cancellation_token: CancellationToken,
|
||||
finish_outcome: Arc<OnceCell<Option<NetworkApprovalOutcome>>>,
|
||||
_execution_proxy: Option<NetworkProxy>,
|
||||
}
|
||||
|
||||
impl DeferredNetworkApproval {
|
||||
@@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval {
|
||||
registration_id: Option<String>,
|
||||
mode: NetworkApprovalMode,
|
||||
cancellation_token: CancellationToken,
|
||||
execution_proxy: NetworkProxy,
|
||||
}
|
||||
|
||||
impl ActiveNetworkApproval {
|
||||
@@ -100,11 +104,16 @@ impl ActiveNetworkApproval {
|
||||
self.cancellation_token.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn execution_proxy(&self) -> &NetworkProxy {
|
||||
&self.execution_proxy
|
||||
}
|
||||
|
||||
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
|
||||
let ActiveNetworkApproval {
|
||||
registration_id,
|
||||
mode,
|
||||
cancellation_token,
|
||||
execution_proxy,
|
||||
} = self;
|
||||
match (mode, registration_id) {
|
||||
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
|
||||
@@ -112,6 +121,7 @@ impl ActiveNetworkApproval {
|
||||
registration_id,
|
||||
cancellation_token,
|
||||
finish_outcome: Arc::new(OnceCell::new()),
|
||||
_execution_proxy: Some(execution_proxy),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
@@ -241,6 +251,11 @@ enum ActiveNetworkApprovalAttribution {
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
struct NetworkRequestAttribution {
|
||||
owner_call: Option<Arc<ActiveNetworkApprovalCall>>,
|
||||
environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NetworkApprovalCallState {
|
||||
active_calls: IndexMap<String, Arc<ActiveNetworkApprovalCall>>,
|
||||
@@ -305,9 +320,8 @@ impl NetworkApprovalService {
|
||||
|
||||
async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {
|
||||
let calls = self.calls.lock().await;
|
||||
// Blocked proxy requests are not attributed to a specific tool call. Only pick an owner
|
||||
// when there is exactly one candidate; with concurrent calls, canceling one would be a guess.
|
||||
// TODO: Carry blocked-request attribution so concurrent active calls can be handled safely.
|
||||
// Shared proxy requests can still arrive without an execution ID. Only pick an owner when
|
||||
// there is exactly one candidate; with concurrent calls, canceling one would be a guess.
|
||||
if calls.active_calls.len() == 1 {
|
||||
return calls.active_calls.values().next().cloned();
|
||||
}
|
||||
@@ -315,6 +329,18 @@ impl NetworkApprovalService {
|
||||
None
|
||||
}
|
||||
|
||||
async fn resolve_active_call_by_execution_id(
|
||||
&self,
|
||||
execution_id: &str,
|
||||
) -> Option<Arc<ActiveNetworkApprovalCall>> {
|
||||
self.calls
|
||||
.lock()
|
||||
.await
|
||||
.active_calls
|
||||
.get(execution_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution {
|
||||
let calls = self.calls.lock().await;
|
||||
match calls.active_calls.len() {
|
||||
@@ -327,6 +353,54 @@ impl NetworkApprovalService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_request_attribution(
|
||||
&self,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Option<NetworkRequestAttribution> {
|
||||
if let Some(execution_id) = request.execution_id.as_deref() {
|
||||
let call = self
|
||||
.resolve_active_call_by_execution_id(execution_id)
|
||||
.await?;
|
||||
let environment_id = request
|
||||
.environment_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| call.environment_id.clone());
|
||||
return (call.environment_id == environment_id).then_some(NetworkRequestAttribution {
|
||||
owner_call: Some(call),
|
||||
environment_id: Some(environment_id),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(environment_id) = request.environment_id.clone() {
|
||||
let owner_call = match self.resolve_active_call_attribution().await {
|
||||
ActiveNetworkApprovalAttribution::Single(call) => {
|
||||
(call.environment_id == environment_id).then_some(call)
|
||||
}
|
||||
ActiveNetworkApprovalAttribution::None
|
||||
| ActiveNetworkApprovalAttribution::Ambiguous => None,
|
||||
};
|
||||
return Some(NetworkRequestAttribution {
|
||||
owner_call,
|
||||
environment_id: Some(environment_id),
|
||||
});
|
||||
}
|
||||
|
||||
match self.resolve_active_call_attribution().await {
|
||||
ActiveNetworkApprovalAttribution::None => Some(NetworkRequestAttribution {
|
||||
owner_call: None,
|
||||
environment_id: None,
|
||||
}),
|
||||
ActiveNetworkApprovalAttribution::Single(call) => {
|
||||
let environment_id = call.environment_id.clone();
|
||||
Some(NetworkRequestAttribution {
|
||||
owner_call: Some(call),
|
||||
environment_id: Some(environment_id),
|
||||
})
|
||||
}
|
||||
ActiveNetworkApprovalAttribution::Ambiguous => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_create_pending_approval(
|
||||
&self,
|
||||
key: HostApprovalKey,
|
||||
@@ -393,8 +467,12 @@ impl NetworkApprovalService {
|
||||
return;
|
||||
};
|
||||
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))
|
||||
.await;
|
||||
let outcome = NetworkApprovalOutcome::DeniedByPolicy(message);
|
||||
if let Some(execution_id) = blocked.execution_id.as_deref() {
|
||||
self.record_call_outcome(execution_id, outcome).await;
|
||||
} else {
|
||||
self.record_outcome_for_single_active_call(outcome).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn active_turn_context(
|
||||
@@ -431,28 +509,13 @@ impl NetworkApprovalService {
|
||||
NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp,
|
||||
NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,
|
||||
};
|
||||
let (owner_call, active_environment_id) =
|
||||
if let Some(environment_id) = request.environment_id.clone() {
|
||||
let owner_call = match self.resolve_active_call_attribution().await {
|
||||
ActiveNetworkApprovalAttribution::Single(call) => {
|
||||
(call.environment_id == environment_id).then_some(call)
|
||||
}
|
||||
ActiveNetworkApprovalAttribution::None
|
||||
| ActiveNetworkApprovalAttribution::Ambiguous => None,
|
||||
};
|
||||
(owner_call, Some(environment_id))
|
||||
} else {
|
||||
match self.resolve_active_call_attribution().await {
|
||||
ActiveNetworkApprovalAttribution::None => (None, None),
|
||||
ActiveNetworkApprovalAttribution::Single(call) => {
|
||||
let environment_id = call.environment_id.clone();
|
||||
(Some(call), Some(environment_id))
|
||||
}
|
||||
ActiveNetworkApprovalAttribution::Ambiguous => {
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(NetworkRequestAttribution {
|
||||
owner_call,
|
||||
environment_id: active_environment_id,
|
||||
}) = self.resolve_request_attribution(&request).await
|
||||
else {
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
};
|
||||
let turn_context = Self::active_turn_context(session.as_ref()).await;
|
||||
let Some(environment_id) = active_environment_id.or_else(|| {
|
||||
turn_context
|
||||
@@ -788,20 +851,39 @@ pub(crate) async fn begin_network_approval(
|
||||
session: &Session,
|
||||
turn_id: &str,
|
||||
managed_network_active: bool,
|
||||
selected_sandbox: SandboxType,
|
||||
spec: Option<NetworkApprovalSpec>,
|
||||
) -> Option<ActiveNetworkApproval> {
|
||||
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
|
||||
let NetworkApprovalSpec {
|
||||
network,
|
||||
mode,
|
||||
trigger,
|
||||
command,
|
||||
environment_id,
|
||||
} = spec?;
|
||||
if !managed_network_active || network.is_none() {
|
||||
return None;
|
||||
} = match spec {
|
||||
Some(spec) => spec,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let Some(network) = network else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !managed_network_active {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let registration_id = Uuid::new_v4().to_string();
|
||||
let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp {
|
||||
let attribution_token = Uuid::new_v4().to_string();
|
||||
network
|
||||
.for_execution(&environment_id, ®istration_id, attribution_token)
|
||||
.map_err(|err| {
|
||||
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
|
||||
format!("failed to create execution-scoped network proxy: {err}"),
|
||||
)))
|
||||
})?
|
||||
} else {
|
||||
network.clone()
|
||||
};
|
||||
let cancellation_token = CancellationToken::new();
|
||||
session
|
||||
.services
|
||||
@@ -816,11 +898,12 @@ pub(crate) async fn begin_network_approval(
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(ActiveNetworkApproval {
|
||||
Ok(Some(ActiveNetworkApproval {
|
||||
registration_id: Some(registration_id),
|
||||
mode,
|
||||
cancellation_token,
|
||||
})
|
||||
execution_proxy,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn finish_immediate_network_approval(
|
||||
|
||||
@@ -285,6 +285,12 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
|
||||
})
|
||||
}
|
||||
|
||||
fn denied_blocked_request_for_execution(host: &str, execution_id: &str) -> BlockedRequest {
|
||||
let mut blocked = denied_blocked_request(host);
|
||||
blocked.execution_id = Some(execution_id.to_string());
|
||||
blocked
|
||||
}
|
||||
|
||||
async fn register_call_with_default_shell_trigger(
|
||||
service: &NetworkApprovalService,
|
||||
registration_id: &str,
|
||||
@@ -429,6 +435,7 @@ async fn deferred_finish_reuses_denial_result_after_first_consumer() {
|
||||
registration_id: "registration-1".to_string(),
|
||||
cancellation_token,
|
||||
finish_outcome: Arc::new(OnceCell::new()),
|
||||
_execution_proxy: None,
|
||||
};
|
||||
service
|
||||
.record_call_outcome(
|
||||
@@ -481,3 +488,27 @@ async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests(
|
||||
assert_eq!(service.take_call_outcome("registration-1").await, None);
|
||||
assert_eq!(service.take_call_outcome("registration-2").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attributed_blocked_request_targets_one_of_multiple_active_calls() {
|
||||
let service = NetworkApprovalService::default();
|
||||
let first = register_call_with_default_shell_trigger(&service, "registration-1").await;
|
||||
let second = register_call_with_default_shell_trigger(&service, "registration-2").await;
|
||||
|
||||
service
|
||||
.record_blocked_request(denied_blocked_request_for_execution(
|
||||
"example.com",
|
||||
"registration-2",
|
||||
))
|
||||
.await;
|
||||
|
||||
assert!(!first.is_cancelled());
|
||||
assert!(second.is_cancelled());
|
||||
assert_eq!(service.take_call_outcome("registration-1").await, None);
|
||||
assert_eq!(
|
||||
service.take_call_outcome("registration-2").await,
|
||||
Some(NetworkApprovalOutcome::DeniedByPolicy(
|
||||
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ caching).
|
||||
use crate::guardian::guardian_rejection_message;
|
||||
use crate::guardian::guardian_timeout_message;
|
||||
use crate::guardian::new_guardian_review_id;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::hook_runtime::run_permission_request_hooks;
|
||||
use crate::network_policy_decision::network_approval_context_from_payload;
|
||||
@@ -68,13 +69,18 @@ impl ToolOrchestrator {
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
{
|
||||
let network_approval = begin_network_approval(
|
||||
let network_approval = match begin_network_approval(
|
||||
&tool_ctx.session,
|
||||
&tool_ctx.turn.sub_id,
|
||||
managed_network_active,
|
||||
attempt.sandbox,
|
||||
tool.network_approval_spec(req, tool_ctx),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
Ok(network_approval) => network_approval,
|
||||
Err(err) => return (Err(err), None),
|
||||
};
|
||||
|
||||
let attempt_tool_ctx = ToolCtx {
|
||||
session: tool_ctx.session.clone(),
|
||||
@@ -98,6 +104,9 @@ impl ToolOrchestrator {
|
||||
network_denial_cancellation_token: network_approval
|
||||
.as_ref()
|
||||
.map(ActiveNetworkApproval::cancellation_token),
|
||||
network_proxy: network_approval
|
||||
.as_ref()
|
||||
.map(ActiveNetworkApproval::execution_proxy),
|
||||
};
|
||||
let run_result = tool
|
||||
.run(req, &attempt_with_network_approval, &attempt_tool_ctx)
|
||||
@@ -274,6 +283,7 @@ impl ToolOrchestrator {
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
network_denial_cancellation_token: None,
|
||||
network_proxy: None,
|
||||
};
|
||||
|
||||
let initial_attempt_start = Instant::now();
|
||||
@@ -456,6 +466,7 @@ impl ToolOrchestrator {
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
network_denial_cancellation_token: None,
|
||||
network_proxy: None,
|
||||
};
|
||||
|
||||
// Second attempt.
|
||||
@@ -563,7 +574,26 @@ impl ToolOrchestrator {
|
||||
} else {
|
||||
ToolDecisionSource::User
|
||||
};
|
||||
let decision = tool.start_approval_async(req, approval_ctx).await;
|
||||
let decision = if let Some(review_id) = approval_ctx.guardian_review_id.clone() {
|
||||
match tool.approval_action(req, &approval_ctx) {
|
||||
Ok(action) => {
|
||||
review_approval_request(
|
||||
approval_ctx.session,
|
||||
approval_ctx.turn,
|
||||
review_id,
|
||||
action,
|
||||
approval_ctx.retry_reason.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(%err, "failed to build guardian approval action");
|
||||
ReviewDecision::Abort
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tool.start_approval_async(req, approval_ctx).await
|
||||
};
|
||||
let tool_name = flat_tool_name(&tool_ctx.tool_name);
|
||||
otel.tool_decision(
|
||||
tool_name.as_ref(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
@@ -9,6 +10,7 @@ use tokio_util::either::Either;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::Instrument;
|
||||
use tracing::info;
|
||||
use tracing::instrument;
|
||||
use tracing::trace_span;
|
||||
|
||||
@@ -27,6 +29,15 @@ use crate::tools::router::ToolRouter;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
struct ToolCallTimingGuard {
|
||||
started_at: Instant,
|
||||
execution_started_at: Arc<OnceLock<Instant>>,
|
||||
conversation_id: String,
|
||||
turn_id: String,
|
||||
call_id: String,
|
||||
tool_name: codex_tools::ToolName,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ToolCallRuntime {
|
||||
router: Arc<ToolRouter>,
|
||||
@@ -96,6 +107,11 @@ impl ToolCallRuntime {
|
||||
let invocation_cancellation_token = cancellation_token.clone();
|
||||
let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call);
|
||||
let started = Instant::now();
|
||||
let tool_call_timing_guard =
|
||||
ToolCallTimingGuard::capture(started, &session.thread_id, &turn.sub_id, &call, &source);
|
||||
let execution_started_at = tool_call_timing_guard
|
||||
.as_ref()
|
||||
.map(|timing| Arc::clone(&timing.execution_started_at));
|
||||
let abort_session = Arc::clone(&session);
|
||||
let abort_source = source.clone();
|
||||
let abort_turn = Arc::clone(&turn);
|
||||
@@ -112,13 +128,18 @@ impl ToolCallRuntime {
|
||||
);
|
||||
let abort_dispatch_span = dispatch_span.clone();
|
||||
|
||||
let mut handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
|
||||
let mut dispatch_handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
|
||||
AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
let _guard = if supports_parallel {
|
||||
Either::Left(lock.read().await)
|
||||
} else {
|
||||
Either::Right(lock.write().await)
|
||||
};
|
||||
// Admission through the parallel-execution gate marks the end
|
||||
// of dispatch waiting and the start of handler execution.
|
||||
if let Some(execution_started_at) = execution_started_at {
|
||||
let _ = execution_started_at.set(Instant::now());
|
||||
}
|
||||
|
||||
router
|
||||
.dispatch_tool_call_with_terminal_outcome(
|
||||
@@ -135,28 +156,29 @@ impl ToolCallRuntime {
|
||||
}));
|
||||
|
||||
async move {
|
||||
let _tool_call_timing_guard = tool_call_timing_guard;
|
||||
tokio::select! {
|
||||
res = &mut handle => res.map_err(Self::tool_task_join_error)?,
|
||||
res = &mut dispatch_handle => res.map_err(Self::tool_task_join_error)?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
if terminal_outcome_reached.load(Ordering::Acquire) || handle.is_finished() {
|
||||
handle.await.map_err(Self::tool_task_join_error)?
|
||||
if terminal_outcome_reached.load(Ordering::Acquire) || dispatch_handle.is_finished() {
|
||||
dispatch_handle.await.map_err(Self::tool_task_join_error)?
|
||||
} else {
|
||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||
abort_dispatch_span.record("aborted", true);
|
||||
if wait_for_runtime_cancellation {
|
||||
if terminal_outcome_reached.swap(true, Ordering::AcqRel) {
|
||||
return handle.await.map_err(Self::tool_task_join_error)?;
|
||||
return dispatch_handle.await.map_err(Self::tool_task_join_error)?;
|
||||
}
|
||||
// The abort owns the terminal outcome; await only so
|
||||
// the runtime can finish process teardown.
|
||||
match handle.await {
|
||||
match dispatch_handle.await {
|
||||
Ok(_) => {}
|
||||
Err(err) if err.is_cancelled() => {}
|
||||
Err(err) => return Err(Self::tool_task_join_error(err)),
|
||||
}
|
||||
} else {
|
||||
handle.abort();
|
||||
match handle.await {
|
||||
dispatch_handle.abort();
|
||||
match dispatch_handle.await {
|
||||
Ok(result) => return result,
|
||||
Err(err) if err.is_cancelled() => {}
|
||||
Err(err) => return Err(Self::tool_task_join_error(err)),
|
||||
@@ -237,6 +259,88 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCallTimingGuard {
|
||||
fn capture(
|
||||
started_at: Instant,
|
||||
conversation_id: &impl std::fmt::Display,
|
||||
turn_id: &str,
|
||||
call: &ToolCall,
|
||||
source: &ToolCallSource,
|
||||
) -> Option<Self> {
|
||||
// Code-mode calls are nested within a direct code-mode tool call whose
|
||||
// timing already includes them. Suppress nested guards so consumers do
|
||||
// not mistake overlapping events for independent tool-call latency.
|
||||
if !matches!(source, ToolCallSource::Direct) || !tracing::enabled!(tracing::Level::INFO) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
started_at,
|
||||
execution_started_at: Arc::new(OnceLock::new()),
|
||||
conversation_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
call_id: call.call_id.clone(),
|
||||
tool_name: call.tool_name.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ToolCallTimingGuard {
|
||||
fn drop(&mut self) {
|
||||
let completed_at = Instant::now();
|
||||
// Snapshot once so a concurrently-starting dispatch cannot make one
|
||||
// event internally inconsistent.
|
||||
let execution_started_at = self
|
||||
.execution_started_at
|
||||
.get()
|
||||
.copied()
|
||||
.filter(|execution_started_at| *execution_started_at <= completed_at);
|
||||
let duration_ms = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();
|
||||
let total_duration_ms = duration_ms(completed_at.duration_since(self.started_at));
|
||||
let dispatch_duration_ms = execution_started_at.map_or_else(
|
||||
|| total_duration_ms,
|
||||
|execution_started_at| {
|
||||
duration_ms(execution_started_at.duration_since(self.started_at))
|
||||
},
|
||||
);
|
||||
let handler_duration_ms = execution_started_at.map_or(Some(0), |execution_started_at| {
|
||||
duration_ms(completed_at.duration_since(execution_started_at))
|
||||
});
|
||||
|
||||
macro_rules! log_tool_call {
|
||||
($dispatch_duration_ms:expr, $handler_duration_ms:expr, $total_duration_ms:expr) => {
|
||||
info!(
|
||||
event.name = "codex.tool_call",
|
||||
trace_id = %codex_otel::current_span_trace_id().unwrap_or_default(),
|
||||
conversation.id = %self.conversation_id,
|
||||
turn_id = %self.turn_id,
|
||||
tool_name = %self.tool_name,
|
||||
call_id = %self.call_id,
|
||||
tool_source = "direct",
|
||||
execution_started = execution_started_at.is_some(),
|
||||
dispatch_duration_ms = $dispatch_duration_ms,
|
||||
handler_duration_ms = $handler_duration_ms,
|
||||
total_duration_ms = $total_duration_ms,
|
||||
"tool call completed"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
match (dispatch_duration_ms, handler_duration_ms, total_duration_ms) {
|
||||
(Some(dispatch_duration_ms), Some(handler_duration_ms), Some(total_duration_ms)) => {
|
||||
log_tool_call!(dispatch_duration_ms, handler_duration_ms, total_duration_ms);
|
||||
}
|
||||
_ => {
|
||||
log_tool_call!(
|
||||
tracing::field::Empty,
|
||||
tracing::field::Empty,
|
||||
tracing::field::Empty
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -255,6 +359,154 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing_test::internal::MockWriter;
|
||||
|
||||
#[test]
|
||||
fn tool_call_timing_guard_ignores_code_mode_source() {
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let call = ToolCall {
|
||||
tool_name: codex_tools::ToolName::plain("test_tool"),
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let direct_guard = ToolCallTimingGuard::capture(
|
||||
Instant::now(),
|
||||
&"conversation-id",
|
||||
"turn-id",
|
||||
&call,
|
||||
&ToolCallSource::Direct,
|
||||
);
|
||||
assert!(
|
||||
direct_guard.is_some(),
|
||||
"direct tool calls should create a timing guard"
|
||||
);
|
||||
drop(direct_guard);
|
||||
|
||||
let code_mode_guard = ToolCallTimingGuard::capture(
|
||||
Instant::now(),
|
||||
&"conversation-id",
|
||||
"turn-id",
|
||||
&call,
|
||||
&ToolCallSource::CodeMode {
|
||||
cell_id: "cell-1".to_string(),
|
||||
runtime_tool_call_id: "runtime-call-1".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
code_mode_guard.is_none(),
|
||||
"nested code-mode calls should not create overlapping timing events"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_before_dispatch_admission_logs_dispatch_only_timing() -> anyhow::Result<()>
|
||||
{
|
||||
let (session, turn_context) = crate::session::tests::make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let tool_name = codex_tools::ToolName::plain("test_tool");
|
||||
let handler = Arc::new(ImmediateHandler {
|
||||
tool_name: tool_name.clone(),
|
||||
}) as Arc<dyn CoreToolRuntime>;
|
||||
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
||||
let router = Arc::new(ToolRouter::from_parts(
|
||||
ToolRegistry::from_tools([handler]),
|
||||
Vec::new(),
|
||||
));
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let runtime = ToolCallRuntime::new(router, session, step_context, tracker);
|
||||
let execution_gate = Arc::clone(&runtime.parallel_execution);
|
||||
let execution_gate_guard = execution_gate
|
||||
.try_write_owned()
|
||||
.expect("execution gate should be available before dispatch starts");
|
||||
let (release_execution_gate_tx, release_execution_gate_rx) = std::sync::mpsc::channel();
|
||||
let execution_gate_task = tokio::task::spawn_blocking(move || {
|
||||
let _execution_gate_guard = execution_gate_guard;
|
||||
release_execution_gate_rx
|
||||
.recv()
|
||||
.expect("test should release the execution gate");
|
||||
});
|
||||
|
||||
let buffer: &'static std::sync::Mutex<Vec<u8>> =
|
||||
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_writer(MockWriter::new(buffer))
|
||||
.finish();
|
||||
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let call = ToolCall {
|
||||
tool_name,
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let response_task =
|
||||
tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone()));
|
||||
cancellation_token.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(1), response_task)
|
||||
.await
|
||||
.expect("timed out waiting for cancelled tool response")
|
||||
.expect("cancelled tool response task should join")
|
||||
.expect("cancelled tool call should produce a response");
|
||||
|
||||
let logs = String::from_utf8(
|
||||
buffer
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
)?;
|
||||
let timing_events = logs
|
||||
.lines()
|
||||
.filter(|line| line.contains("event.name=\"codex.tool_call\""))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
timing_events.len(),
|
||||
1,
|
||||
"cancelled tool call should emit exactly one timing event; logs:\n{logs}"
|
||||
);
|
||||
let timing_event = timing_events[0];
|
||||
assert!(
|
||||
timing_event.contains("execution_started=false"),
|
||||
"tool cancelled before admission should not report execution started: {timing_event}"
|
||||
);
|
||||
assert!(
|
||||
timing_event.contains("handler_duration_ms=0"),
|
||||
"tool cancelled before admission should report zero handler duration: {timing_event}"
|
||||
);
|
||||
let duration_field = |name: &str| {
|
||||
timing_event.split_whitespace().find_map(|field| {
|
||||
field
|
||||
.strip_prefix(&format!("{name}="))
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
})
|
||||
};
|
||||
let dispatch_duration_ms = duration_field("dispatch_duration_ms")
|
||||
.expect("timing event should include dispatch_duration_ms");
|
||||
let total_duration_ms = duration_field("total_duration_ms")
|
||||
.expect("timing event should include total_duration_ms");
|
||||
assert_eq!(
|
||||
dispatch_duration_ms, total_duration_ms,
|
||||
"tool cancelled before admission should attribute all elapsed time to dispatch: {timing_event}"
|
||||
);
|
||||
release_execution_gate_tx
|
||||
.send(())
|
||||
.expect("execution gate task should remain available");
|
||||
execution_gate_task
|
||||
.await
|
||||
.expect("execution gate task should join");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ImmediateHandler {
|
||||
tool_name: codex_tools::ToolName,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user