diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 72d8298265..b2f4d55bab 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2854,6 +2854,15 @@ "call_id": { "type": "string" }, + "encrypted_function_args": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8d6146328a..ce5216b4d0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16337,6 +16337,15 @@ "call_id": { "type": "string" }, + "encrypted_function_args": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f1cfaa183b..7a09995f87 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12679,6 +12679,15 @@ "call_id": { "type": "string" }, + "encrypted_function_args": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index b20400d16a..d4a19a0aa2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -632,6 +632,15 @@ "call_id": { "type": "string" }, + "encrypted_function_args": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 0ff306cf1b..0bce28ec6a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -702,6 +702,15 @@ "call_id": { "type": "string" }, + "encrypted_function_args": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "id": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 2758941b6d..9e7d0dccf4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -21,4 +21,4 @@ id?: ResponseItemId, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: ResponseItemId, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: ResponseItemId, name: string, namespace?: string, arguments: string, encrypted_function_args?: Array, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: ResponseItemId, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: ResponseItemId, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: ResponseItemId, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: ResponseItemId, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: ResponseItemId, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: ResponseItemId, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: ResponseItemId, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: ResponseItemId, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: ResponseItemId, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index d5b62aca49..4190aca0d8 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -120,6 +120,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { namespace: None, arguments: "{}".to_string(), call_id: call_id.to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/agent_communication.rs b/codex-rs/core/src/agent_communication.rs index e71f50ad96..91022fdc3e 100644 --- a/codex-rs/core/src/agent_communication.rs +++ b/codex-rs/core/src/agent_communication.rs @@ -56,11 +56,10 @@ pub(crate) fn emit_agent_communication_send( 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() - }, + content = communication + .encrypted_content + .as_deref() + .unwrap_or("[plaintext]"), }, "agent communication" ); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b132420620..82b47e0604 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -848,9 +848,16 @@ impl ModelClient { let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); let is_openai = self.state.provider.info().is_openai(); if !is_openai { - input - .iter_mut() - .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); + for item in &mut input { + item.clear_internal_chat_message_metadata_passthrough(); + if let ResponseItem::FunctionCall { + encrypted_function_args, + .. + } = item + { + *encrypted_function_args = None; + } + } } let (instructions, tools) = if model_info.use_responses_lite { let tools = create_tools_json_for_responses_api(&prompt.tools)?; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 034dd10e2a..d20e7738af 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -620,6 +620,7 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { diff --git a/codex-rs/core/src/context/inter_agent_message.rs b/codex-rs/core/src/context/inter_agent_message.rs new file mode 100644 index 0000000000..6ce06527f4 --- /dev/null +++ b/codex-rs/core/src/context/inter_agent_message.rs @@ -0,0 +1,66 @@ +use codex_protocol::AgentPath; + +use super::ContextualUserFragment; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InterAgentMessageType { + Message, + NewTask, +} + +impl InterAgentMessageType { + fn as_str(self) -> &'static str { + match self { + Self::Message => "MESSAGE", + Self::NewTask => "NEW_TASK", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InterAgentMessage { + message_type: InterAgentMessageType, + task_name: AgentPath, + sender: AgentPath, + payload: String, +} + +impl InterAgentMessage { + pub(crate) fn new( + message_type: InterAgentMessageType, + task_name: AgentPath, + sender: AgentPath, + payload: impl Into, + ) -> Self { + Self { + message_type, + task_name, + sender, + payload: payload.into(), + } + } +} + +impl ContextualUserFragment for InterAgentMessage { + fn role(&self) -> &'static str { + "assistant" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + format!( + "Message Type: {}\nTask name: {}\nSender: {}\nPayload:\n{}", + self.message_type.as_str(), + self.task_name, + self.sender, + self.payload, + ) + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index cfe78d3eb5..fc52f93217 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -11,6 +11,7 @@ mod environments_instructions; mod guardian_followup_review_reminder; mod hook_additional_context; mod inter_agent_completion_message; +mod inter_agent_message; mod internal_model_context; mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; @@ -51,6 +52,8 @@ pub(crate) use environments_instructions::EnvironmentsInstructions; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use inter_agent_completion_message::InterAgentCompletionMessage; +pub(crate) use inter_agent_message::InterAgentMessage; +pub(crate) use inter_agent_message::InterAgentMessageType; pub use internal_model_context::InternalContextSource; pub use internal_model_context::InternalModelContextFragment; pub use internal_model_context::InvalidInternalContextSource; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 97d65fc396..cffec26a59 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -550,6 +550,7 @@ fn for_prompt_strips_media_when_model_does_not_support_it() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -631,6 +632,7 @@ fn for_prompt_strips_media_when_model_does_not_support_it() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -851,6 +853,7 @@ fn remove_first_item_removes_matching_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -880,6 +883,7 @@ fn remove_first_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ]; @@ -1392,6 +1396,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1407,6 +1412,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -1548,6 +1554,7 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, // Orphan output that should be removed @@ -1595,6 +1602,7 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -1650,6 +1658,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1663,6 +1672,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { @@ -1684,6 +1694,7 @@ fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { @@ -1894,6 +1905,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 03b14f91d7..cb52f920d0 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -308,6 +308,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc>; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ToolCallSource { Direct, + DirectPlaintextMessage, CodeMode { /// Runtime cell that issued the nested tool request. cell_id: String, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 7ee0205eca..ad9f38927d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -2,6 +2,9 @@ use crate::agent::AgentStatus; use crate::agent::agent_resolver::resolve_agent_target; +use crate::context::ContextualUserFragment; +use crate::context::InterAgentMessage; +use crate::context::InterAgentMessageType; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -51,16 +54,31 @@ pub(crate) async fn emit_sub_agent_activity( session.emit_turn_item_completed(turn, item).await; } -pub(super) fn communication_from_tool_message( +fn communication_from_tool_message( author: AgentPath, recipient: AgentPath, message: String, + source: &crate::tools::context::ToolCallSource, + trigger_turn: bool, ) -> InterAgentCommunication { - InterAgentCommunication::new_encrypted( - author, - recipient, - Vec::new(), - message, - /*trigger_turn*/ true, - ) + if !matches!( + source, + crate::tools::context::ToolCallSource::DirectPlaintextMessage + ) { + return InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + trigger_turn, + ); + } + let message_type = if trigger_turn { + InterAgentMessageType::NewTask + } else { + InterAgentMessageType::Message + }; + let content = + InterAgentMessage::new(message_type, recipient.clone(), author.clone(), message).render(); + InterAgentCommunication::new(author, recipient, Vec::new(), content, trigger_turn) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 0ffe29562c..eb1790480e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -7,7 +7,6 @@ use super::*; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; use crate::tools::context::FunctionToolOutput; -use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum MessageDeliveryMode { @@ -16,17 +15,10 @@ pub(crate) enum MessageDeliveryMode { } impl MessageDeliveryMode { - /// Returns whether the produced communication should start a turn immediately. - fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication { + fn trigger_turn(self) -> bool { match self { - Self::QueueOnly => InterAgentCommunication { - trigger_turn: false, - ..communication - }, - Self::TriggerTurn => InterAgentCommunication { - trigger_turn: true, - ..communication - }, + Self::QueueOnly => false, + Self::TriggerTurn => true, } } } @@ -68,6 +60,7 @@ pub(crate) async fn handle_message_string_tool( session, turn, call_id, + source, .. } = invocation; let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; @@ -100,8 +93,13 @@ pub(crate) async fn handle_message_string_tool( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = - communication_from_tool_message(author, receiver_agent_path.clone(), message); + let communication = communication_from_tool_message( + author, + receiver_agent_path.clone(), + message, + &source, + mode.trigger_turn(), + ); let kind = match mode { MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message, MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup, @@ -112,12 +110,7 @@ pub(crate) async fn handle_message_string_tool( let result = session .services .agent_control - .send_inter_agent_communication( - receiver_thread_id, - mode.apply(communication), - context, - parent_turn_id, - ) + .send_inter_agent_communication(receiver_thread_id, communication, context, parent_turn_id) .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 2fc0b1fa64..4d1aa3094d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -44,18 +44,19 @@ async fn handle_spawn_agent( turn, payload, call_id, + source, .. } = invocation; let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; let fork_mode = args.fork_mode()?; + let message = message_content(args.message)?; let role_name = args .agent_type .as_deref() .map(str::trim) .filter(|role| !role.is_empty()); - 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 = @@ -103,7 +104,13 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let communication = communication_from_tool_message(author, new_agent_path.clone(), message); + let communication = communication_from_tool_message( + author, + new_agent_path.clone(), + message, + &source, + /*trigger_turn*/ true, + ); let context = AgentCommunicationContext::new(AgentCommunicationKind::Spawn, session.thread_id); let spawned_agent = Box::pin( session diff --git a/codex-rs/core/src/tools/lifecycle.rs b/codex-rs/core/src/tools/lifecycle.rs index ad8b492cce..974ef88bd7 100644 --- a/codex-rs/core/src/tools/lifecycle.rs +++ b/codex-rs/core/src/tools/lifecycle.rs @@ -86,7 +86,9 @@ async fn notify_tool_finish_parts( fn extension_tool_call_source(source: ToolCallSource) -> ExtensionToolCallSource { match source { - ToolCallSource::Direct => ExtensionToolCallSource::Direct, + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => { + ExtensionToolCallSource::Direct + } ToolCallSource::CodeMode { cell_id, runtime_tool_call_id, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index e201f0ffce..7bb590d334 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -78,8 +78,8 @@ impl ToolCallRuntime { cancellation_token: CancellationToken, ) -> impl std::future::Future> { let error_call = call.clone(); - let future = - self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token); + let source = call.direct_source(); + let future = self.handle_tool_call_with_source(call, source, cancellation_token); async move { match future.await { Ok(response) => Ok(response.into_response()), @@ -270,7 +270,11 @@ impl ToolCallTimingGuard { // 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) { + if !matches!( + source, + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage + ) || !tracing::enabled!(tracing::Level::INFO) + { return None; } @@ -373,6 +377,7 @@ mod tests { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; let direct_guard = ToolCallTimingGuard::capture( Instant::now(), @@ -449,6 +454,7 @@ mod tests { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; let response_task = tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); @@ -692,6 +698,7 @@ mod tests { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; let response_task = @@ -765,6 +772,7 @@ mod tests { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; let response_task = diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 3000c518ba..fc6a3aaba3 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -24,6 +24,7 @@ use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE; use crate::tools::hook_names::HookToolName; use crate::tools::lifecycle::notify_tool_finish; use crate::tools::lifecycle::notify_tool_start; +use crate::tools::router::tool_log_payload; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::util::error_or_panic; use codex_extension_api::ToolCallOutcome; @@ -465,7 +466,7 @@ impl ToolRegistry { Some(tool) => tool, None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); - let log_payload = invocation.payload.log_payload(); + let log_payload = tool_log_payload(&invocation.payload, &invocation.source); otel.tool_result_with_tags( tool_name_flat.as_ref(), &call_id_owned, @@ -496,7 +497,7 @@ impl ToolRegistry { } if !tool.matches_kind(&invocation.payload) { let message = format!("tool {tool_name} invoked with incompatible payload"); - let log_payload = invocation.payload.log_payload(); + let log_payload = tool_log_payload(&invocation.payload, &invocation.source); otel.tool_result_with_tags( tool_name_flat.as_ref(), &call_id_owned, @@ -578,7 +579,7 @@ impl ToolRegistry { let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); - let log_payload = invocation.payload.log_payload(); + let log_payload = tool_log_payload(&invocation.payload, &invocation.source); let result = otel .log_tool_result_with_tags( diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 5a5d6686ba..5e48e65d23 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -20,6 +20,7 @@ use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; +use std::borrow::Cow; use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -33,6 +34,36 @@ pub struct ToolCall { pub tool_name: ToolName, pub call_id: String, pub payload: ToolPayload, + pub encrypted_function_args: Option>, +} + +impl ToolCall { + pub(crate) fn direct_source(&self) -> ToolCallSource { + if self.tool_name.namespace.as_deref() == Some("collaboration") + && matches!( + self.tool_name.name.as_str(), + "spawn_agent" | "send_message" | "followup_task" + ) + && self + .encrypted_function_args + .as_ref() + .is_some_and(Vec::is_empty) + { + ToolCallSource::DirectPlaintextMessage + } else { + ToolCallSource::Direct + } + } +} + +pub(crate) fn tool_log_payload<'a>( + payload: &'a ToolPayload, + source: &ToolCallSource, +) -> Cow<'a, str> { + if matches!(source, ToolCallSource::DirectPlaintextMessage) { + return Cow::Borrowed("[plaintext arguments]"); + } + payload.log_payload() } pub struct ToolRouter { @@ -131,6 +162,7 @@ impl ToolRouter { name, namespace, arguments, + encrypted_function_args, call_id, .. } => { @@ -139,6 +171,7 @@ impl ToolRouter { tool_name, call_id, payload: ToolPayload::Function { arguments }, + encrypted_function_args, })) } ResponseItem::ToolSearchCall { @@ -157,6 +190,7 @@ impl ToolRouter { tool_name: ToolName::plain("tool_search"), call_id, payload: ToolPayload::ToolSearch { arguments }, + encrypted_function_args: None, })) } ResponseItem::ToolSearchCall { .. } => Ok(None), @@ -170,6 +204,7 @@ impl ToolRouter { tool_name: ToolName::new(namespace, name), call_id, payload: ToolPayload::Custom { input }, + encrypted_function_args: None, })), _ => Ok(None), } @@ -237,6 +272,7 @@ impl ToolRouter { tool_name, call_id, payload, + .. } = call; // Keep the legacy ToolInvocation.turn field tied to the same request state until handlers migrate. diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 95501d9ecd..bd33221736 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -37,9 +37,25 @@ use super::ToolCallSource; use super::ToolRouter; use super::ToolRouterParams; use super::extension_tool_executors; +use super::tool_log_payload; struct ExtensionEchoContributor; +#[test] +fn tool_log_payload_redacts_plaintext_multi_agent_messages() { + let payload = ToolPayload::Function { + arguments: json!({"target": "/root/worker", "message": "secret message"}).to_string(), + }; + assert_eq!( + tool_log_payload(&payload, &ToolCallSource::DirectPlaintextMessage), + "[plaintext arguments]" + ); + assert_eq!( + tool_log_payload(&payload, &ToolCallSource::Direct), + payload.log_payload() + ); +} + impl codex_extension_api::ToolContributor for ExtensionEchoContributor { fn tools( &self, @@ -135,6 +151,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }) }) .expect("test session should expose a parallel shell-like tool"); @@ -145,6 +162,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, })); Ok(()) @@ -159,6 +177,7 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() name: tool_name.clone(), namespace: Some("mcp__codex_apps__calendar".to_string()), arguments: "{}".to_string(), + encrypted_function_args: Some(Vec::new()), call_id: "call-namespace".to_string(), internal_chat_message_metadata_passthrough: None, })? @@ -169,6 +188,8 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() ToolName::namespaced("mcp__codex_apps__calendar", tool_name) ); assert_eq!(call.call_id, "call-namespace"); + assert_eq!(call.encrypted_function_args, Some(Vec::new())); + assert_eq!(call.direct_source(), ToolCallSource::Direct); match call.payload { ToolPayload::Function { arguments } => { assert_eq!(arguments, "{}"); @@ -202,6 +223,7 @@ async fn build_custom_tool_call_uses_namespace_for_registry_name() -> anyhow::Re payload: ToolPayload::Custom { input: "print('hello')".to_string(), }, + encrypted_function_args: None, } ); @@ -246,6 +268,7 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; assert!(router.tool_supports_parallel(&call)); @@ -255,6 +278,7 @@ async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> { payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, }; assert!(!router.tool_supports_parallel(&different_server_call)); @@ -286,6 +310,7 @@ async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> payload: ToolPayload::Function { arguments: "{}".to_string(), }, + encrypted_function_args: None, })); Ok(()) @@ -438,6 +463,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: "call-extension".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index 098dc8ddef..cb7c34aa93 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -61,9 +61,11 @@ impl ToolDispatchTrace { fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option { let requester = match &invocation.source { - ToolCallSource::Direct => ToolDispatchRequester::Model { - model_visible_call_id: invocation.call_id.clone(), - }, + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => { + ToolDispatchRequester::Model { + model_visible_call_id: invocation.call_id.clone(), + } + } ToolCallSource::CodeMode { cell_id, runtime_tool_call_id, @@ -91,9 +93,11 @@ fn tool_dispatch_result( result: &dyn ToolOutput, ) -> Option { match invocation.source { - ToolCallSource::Direct => Some(ToolDispatchResult::DirectResponse { - response_item: result.to_response_item(call_id, payload), - }), + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => { + Some(ToolDispatchResult::DirectResponse { + response_item: result.to_response_item(call_id, payload), + }) + } ToolCallSource::CodeMode { .. } => Some(ToolDispatchResult::CodeModeResponse { value: result.code_mode_result(payload), }), diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 29eee14fc8..add55dae59 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -150,6 +150,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, } )); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index be0b2986b3..895f45b732 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -254,9 +254,18 @@ async fn openai_stateless_responses_requests_preserve_item_turn_metadata_across_ #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn non_openai_responses_requests_include_item_ids_without_passthrough_metadata() { let server = MockServer::start().await; - let response_mock = mount_sse_once( + let mut private_function_call = ev_function_call("private-call", "unsupported_tool", "{}"); + private_function_call["item"]["encrypted_function_args"] = json!(["message"]); + let response_mock = mount_sse_sequence( &server, - sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + vec![ + sse(vec![ + ev_response_created("resp1"), + private_function_call, + ev_completed("resp1"), + ]), + sse(vec![ev_response_created("resp2"), ev_completed("resp2")]), + ], ) .await; let mut provider = @@ -289,7 +298,11 @@ async fn non_openai_responses_requests_include_item_ids_without_passthrough_meta .unwrap(); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; - let body = response_mock.single_request().body_json(); + let body = response_mock + .requests() + .pop() + .expect("follow-up request") + .body_json(); let input = body["input"] .as_array() .expect("request should include input items"); @@ -300,6 +313,10 @@ async fn non_openai_responses_requests_include_item_ids_without_passthrough_meta .is_none(), "input item should omit internal chat message metadata passthrough: {item}" ); + assert!( + item.get("encrypted_function_args").is_none(), + "input item should omit private encrypted function metadata: {item}" + ); assert!( item.get("id").and_then(serde_json::Value::as_str).is_some(), "input item should include a generated ID: {item}" @@ -508,6 +525,7 @@ async fn synthetic_call_output_id_is_stable_across_resumes() -> anyhow::Result<( namespace: None, arguments: "{}".to_string(), call_id: function_call_id.to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }), }, @@ -1169,6 +1187,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { namespace: None, arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }), }, @@ -3276,6 +3295,7 @@ async fn azure_responses_request_includes_store_and_prefixed_item_ids() { namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 76efa8c2dc..78574ba7bc 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1400,8 +1400,10 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[test_case(false; "encrypted")] +#[test_case(true; "plaintext")] #[tokio::test] -async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { +async fn multi_agent_v2_spawn_sends_agent_message_to_child(plaintext: bool) -> Result<()> { let output: &'static Mutex> = Box::leak(Box::new(Mutex::new(Vec::new()))); let subscriber = tracing_subscriber::fmt() .with_ansi(false) @@ -1411,22 +1413,30 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result let _guard = tracing::subscriber::set_default(subscriber); let server = start_mock_server().await; - let encrypted_message = "opaque-encrypted-message"; + let message = if plaintext { + "plaintext delegated task" + } else { + "opaque-encrypted-message" + }; let spawn_args = serde_json::to_string(&json!({ - "message": encrypted_message, + "message": message, "task_name": "worker", }))?; + let mut spawn_event = ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ); + if plaintext { + spawn_event["item"]["encrypted_function_args"] = json!([]); + } mount_sse_once_match( &server, |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call_with_namespace( - SPAWN_CALL_ID, - MULTI_AGENT_V2_NAMESPACE, - "spawn_agent", - &spawn_args, - ), + spawn_event, ev_completed("resp-parent-1"), ]), ) @@ -1440,7 +1450,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result ]), ) .await; - mount_sse_once_match( + let parent_request_log = mount_sse_once_match( &server, |req: &wiremock::Request| { body_contains(req, SPAWN_CALL_ID) && !request_has_input_type(req, "agent_message") @@ -1484,6 +1494,25 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result } sleep(Duration::from_millis(10)).await; }; + let content = if plaintext { + vec![json!({ + "type": "input_text", + "text": format!( + "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n{message}" + ), + })] + } else { + vec![ + json!({ + "type": "input_text", + "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n", + }), + json!({ + "type": "encrypted_content", + "encrypted_content": message, + }), + ] + }; assert_eq!( strip_response_item_ids_from_json(strip_metadata_from_json(Value::Array( child_request.inputs_of_type("agent_message"), @@ -1492,18 +1521,20 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "type": "agent_message", "author": "/root", "recipient": "/root/worker", - "content": [ - { - "type": "input_text", - "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n", - }, - { - "type": "encrypted_content", - "encrypted_content": encrypted_message, - }, - ], + "content": content, })]) ); + if plaintext { + assert!( + parent_request_log.requests().into_iter().any(|request| { + request.input().iter().any(|item| { + item["call_id"].as_str() == Some(SPAWN_CALL_ID) + && item["encrypted_function_args"] == json!([]) + }) + }), + "plaintext function-call metadata should survive replay" + ); + } let child_thread_id = test .thread_manager @@ -1530,7 +1561,8 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .expect("spawn send event"); assert!(send.contains(&format!("sender_thread_id={root_thread_id}"))); assert!(send.contains(&format!("receiver_thread_id={child_thread_id}"))); - assert!(send.contains(&format!("content=\"{encrypted_message}\""))); + let logged_message = if plaintext { "[plaintext]" } else { message }; + assert!(send.contains(&format!("content=\"{logged_message}\""))); let communication_id = log_field(send, "communication_id").expect("communication ID"); logs.lines() diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index f7b21c0f14..d7543b33b3 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -100,6 +100,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { namespace: None, arguments: "{}".to_string(), call_id: "mcp-call".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index bd1e8182ff..fb1630c8dc 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -129,6 +129,7 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), + encrypted_function_args: None, internal_chat_message_metadata_passthrough: None, }, previous_assistant, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index dc9d9438a7..29e3e859e1 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -882,6 +882,9 @@ pub enum ResponseItem { // JSON, not as an already‑parsed object. We keep it as a raw string here and let // Session::handle_function_call parse it into a Value. arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + encrypted_function_args: Option>, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -2892,12 +2895,29 @@ mod tests { name: "mcp__codex_apps__gmail_get_recent_emails".to_string(), namespace: Some("mcp__codex_apps__gmail".to_string()), arguments: "{\"top_k\":5}".to_string(), + encrypted_function_args: None, call_id: "call-1".to_string(), internal_chat_message_metadata_passthrough: None, } ); } + #[test] + fn function_call_preserves_empty_encrypted_function_args() { + let value = serde_json::json!({ + "type": "function_call", + "name": "spawn_agent", + "namespace": "collaboration", + "arguments": "{\"message\":\"hello\",\"task_name\":\"worker\"}", + "encrypted_function_args": [], + "call_id": "call-1", + }); + let item: ResponseItem = + serde_json::from_value(value.clone()).expect("function_call should deserialize"); + + assert_eq!(serde_json::to_value(item).expect("serialize item"), value); + } + #[test] fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() { let prefixes = vec![