diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 861ae1dcb..c6e1aa330 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -34,6 +34,30 @@ ], "type": "string" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -2120,6 +2144,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 961e24c2c..f2ab78334 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -288,8 +288,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -326,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 13bac2e11..dbfca64f4 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1593,8 +1593,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -2005,4 +2005,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file 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 4802a5230..bbcbb20be 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 @@ -5900,6 +5900,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -14050,6 +14074,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/v2/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { 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 2b9e30bae..65d999b33 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 @@ -265,6 +265,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -10572,6 +10596,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { 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 f82acbae6..c18f9aeb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ContentItem": { "oneOf": [ { @@ -369,6 +393,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { 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 364cb3661..a6abcc566 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -436,6 +460,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts new file mode 100644 index 000000000..a3bb64559 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index e5e960ff8..b90963db8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; @@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 458d2e43b..149b3aec0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 549852e36..6317dff3b 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -109,7 +109,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -715,7 +716,12 @@ impl AgentControl { agent_id: ThreadId, initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&initial_operation); + let last_task_message = match &initial_operation { + Op::InterAgentCommunication { communication } => { + last_task_message_from_communication(communication) + } + _ => non_empty_task_message(render_input_preview(&initial_operation)), + }; let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -725,8 +731,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -736,7 +746,7 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { - let last_task_message = communication.content.clone(); + let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -748,8 +758,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -1329,6 +1343,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { } } +fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { + if communication.encrypted_content.is_some() { + return None; + } + non_empty_task_message(communication.content.clone()) +} + +fn non_empty_task_message(message: String) -> Option { + (!message.is_empty()).then_some(message) +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f9462e94f..3866d2201 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,62 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("old plaintext task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + Some("old plaintext task".to_string()) + ); + + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + agent_path, + Vec::new(), + "encrypted-task".to_string(), + /*trigger_turn*/ true, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication) + .await + .expect("send_inter_agent_communication should succeed"); + + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + None + ); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085..43aca201b 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -180,6 +180,20 @@ impl AgentRegistry { } } + pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = None; + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d775f9c01..48d0523df 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -3,6 +3,7 @@ use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use codex_tools::ToolSpec; use futures::Stream; use serde_json::Value; @@ -53,7 +54,22 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input.clone() + self.input + .iter() + .cloned() + .map(|item| { + let ResponseItem::Message { role, content, .. } = &item else { + return item; + }; + if role != "assistant" { + return item; + } + InterAgentCommunication::from_message_content(content) + .filter(|communication| communication.encrypted_content.is_some()) + .map(|communication| communication.to_model_input_item()) + .unwrap_or(item) + }) + .collect() } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index a480c1eba..c7730ddfb 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, + ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0be9bbc18..b8bcf9c84 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,6 +386,7 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), }, ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } @@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, } } pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } let ResponseItem::Message { role, content, .. } = item else { return false; }; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4a9316496..996e59187 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1924,6 +1924,7 @@ async fn try_run_sampling_request( role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) } ResponseItem::Reasoning { .. } => true, + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index a15464d9d..891ac5a59 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to queue on the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -199,7 +200,8 @@ pub fn create_followup_task_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to send to the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap InterAgentCommunication { + InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + /*trigger_turn*/ true, + ) +} 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 258d80c85..f23d700e9 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 @@ -1,4 +1,4 @@ -//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! Shared argument parsing and dispatch for the v2 agent messaging tools. //! //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. @@ -55,20 +55,21 @@ fn message_content(message: String) -> Result { Ok(message) } -/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`. +/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, target: String, message: String, ) -> Result { - let prompt = message_content(message)?; + let message = message_content(message)?; let ToolInvocation { session, turn, call_id, .. } = invocation; + let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services @@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ); + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, receiver_agent_path, message); let result = session .services .agent_control 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 7d5b469a4..b5b7d210f 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 @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; -use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; @@ -9,7 +8,6 @@ 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 codex_protocol::AgentPath; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -61,8 +59,9 @@ 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 prompt = render_input_preview(&initial_operation); + let prompt = String::new(); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); @@ -129,17 +128,12 @@ async fn handle_spawn_agent( .iter() .all(|item| matches!(item, UserInput::Text { .. })) => { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, recipient, message); + Op::InterAgentCommunication { communication } } (_, initial_operation) => initial_operation, }, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bf5b1c01b..381accb2f 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1047,6 +1047,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_message_schemas_are_encrypted() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + }) + .await; + for tool_name in ["spawn_agent", "send_message", "followup_task"] { + let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { + panic!("expected {tool_name} function spec"); + }; + let properties = tool + .parameters + .properties + .as_ref() + .expect("tool should use object params"); + assert_eq!( + properties + .get("message") + .and_then(|schema| schema.encrypted), + Some(true) + ); + } +} + #[tokio::test] async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 570e4a6e3..bcb3cb8b9 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -177,6 +177,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { }) }) } + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c39a6bb39..97d60d67c 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,6 +17,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -1026,6 +1027,80 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let server = start_mock_server().await; + let encrypted_message = "opaque-encrypted-message"; + let spawn_args = serde_json::to_string(&json!({ + "message": encrypted_message, + "task_name": "worker", + }))?; + 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(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("resp-child-1"), + ev_completed("resp-child-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("resp-parent-2"), + ev_assistant_message("msg-parent-2", "done"), + ev_completed("resp-parent-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + + let child_request = wait_for_requests(&child_request_log) + .await? + .pop() + .expect("child request"); + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }], + })] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 9c73e96b2..41c06c800 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -242,6 +242,7 @@ fn edit_images(history: &[ResponseItem]) -> Vec { )); } ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 0003cb04a..65ca0c453 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1217,6 +1217,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { ResponseItem::Message { role, .. } => format!("message_from_{role}"), + ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d95370c06..517c33cac 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -715,6 +715,12 @@ pub enum ContentItem { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessageInputContent { + EncryptedContent { encrypted_content: String }, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -758,6 +764,11 @@ pub enum ResponseItem { #[ts(optional)] phase: Option, }, + AgentMessage { + author: String, + recipient: String, + content: Vec, + }, Reasoning { #[serde(default, skip_serializing)] #[ts(skip)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91ef624fa..b3538a27a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::CallToolResult; use crate::mcp::RequestId; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; +use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; @@ -690,6 +691,9 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub encrypted_content: Option, pub trigger_turn: bool, } @@ -706,6 +710,24 @@ impl InterAgentCommunication { recipient, other_recipients, content, + encrypted_content: None, + trigger_turn, + } + } + + pub fn new_encrypted( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + encrypted_content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content: String::new(), + encrypted_content: Some(encrypted_content), trigger_turn, } } @@ -720,6 +742,19 @@ impl InterAgentCommunication { } } + pub fn to_model_input_item(&self) -> ResponseItem { + match &self.encrypted_content { + Some(encrypted_content) => ResponseItem::AgentMessage { + author: self.author.to_string(), + recipient: self.recipient.to_string(), + content: vec![AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }], + }, + None => self.to_response_input_item().into(), + } + } + pub fn is_message_content(content: &[ContentItem]) -> bool { Self::from_message_content(content).is_some() } @@ -4063,6 +4098,7 @@ mod tests { recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), + encrypted_content: None, trigger_turn: true, }; diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index a49b794d1..37f3220c2 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -613,7 +613,12 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; }; let communication = serde_json::from_str::(text).ok()?; - Some((communication.recipient.to_string(), communication.content)) + Some(( + communication.recipient.to_string(), + communication + .encrypted_content + .unwrap_or(communication.content), + )) } #[cfg(test)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 92e5f5690..d1a0045cd 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,6 +30,7 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -60,7 +61,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 1b53ad980..5352eaa91 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -43,6 +43,9 @@ pub struct JsonSchema { pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Responses-only marker for reviewed encrypted tool parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_values: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +93,11 @@ impl JsonSchema { Self::typed(JsonSchemaPrimitiveType::String, description) } + pub fn with_encrypted(mut self) -> Self { + self.encrypted = Some(true); + self + } + pub fn number(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Number, description) } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 9315d43c0..6973d06c0 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -24,6 +24,20 @@ fn parse_tool_input_schema_coerces_boolean_schemas() { assert_eq!(schema, JsonSchema::string(/*description*/ None)); } +#[test] +fn json_schema_serializes_encrypted_marker() { + let schema = JsonSchema::string(Some("Secret value".to_string())).with_encrypted(); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "string", + "description": "Secret value", + "encrypted": true, + }) + ); +} + #[test] fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // Example schema shape: