diff --git a/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs b/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs index 177590a6a2..bc12a8b839 100644 --- a/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/history_notes_extension.rs @@ -205,7 +205,11 @@ async fn app_server_uses_configured_notes_backend_for_context_window_hints( }) .count(); assert_eq!(bridge_calls, usize::from(!use_history_notes_extension)); - let developer_messages = request.message_input_texts("developer"); + let developer_messages: Vec<_> = request + .message_input_text_groups("developer") + .into_iter() + .map(|parts| parts.concat()) + .collect(); let context_window = developer_messages .iter() .find(|text| text.contains("")) diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d7ccab32f..df2b6558b9 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -27,6 +27,7 @@ pub use runtime::McpRuntime; pub use runtime::McpRuntimeContext; pub use runtime::McpRuntimeInput; pub use runtime::McpStartupPolicy; +pub use runtime::McpToolCallResult; pub use runtime::SandboxState; pub use runtime::apply_http_headers_helper; pub use tool_catalog_cache::McpToolCatalogCache; diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index a37f3cc9f4..483cfc618b 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -75,6 +75,13 @@ pub enum McpStartupPolicy { LazyWhenCached, } +/// A host-requested tool result and its identity in the published MCP catalog. +pub struct McpToolCallResult { + pub result: CallToolResult, + /// None when no retained binding identifies the tool. This is not MCP wire metadata. + pub source_tool_namespace: Option, +} + /// Configuration and owning-thread state needed to materialize an MCP runtime. pub struct McpRuntimeInput { pub startup_policy: McpStartupPolicy, @@ -643,6 +650,18 @@ impl McpRuntime { self.latest_connections().list_all_tools().await } + pub async fn latest_read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> anyhow::Result { + self.latest_connections() + .read_resource(server, params) + .await + } + + /// Calls a tool for the host and reports its callable namespace from the same runtime. + /// Missing cached identity never blocks execution; its namespace remains unknown. #[allow(clippy::too_many_arguments)] pub async fn latest_call_tool( &self, @@ -653,8 +672,23 @@ impl McpRuntime { meta: Option, requested_timeout: Option, wait_for_server: bool, - ) -> anyhow::Result { - self.latest_connections() + ) -> anyhow::Result { + let current = self.current.load_full(); + // Attribution must never delay a successful call. The binding is immutable; + // if it does not describe this host-only tool, keep its namespace unknown. + let namespace = current + .cached_binding + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|cached| cached.binding.upgrade()) + .and_then(|binding| { + binding + .tool_info(server, tool) + .map(|info| info.callable_namespace.clone()) + }); + let result = current + .connections .call_tool( server, tool, @@ -664,17 +698,11 @@ impl McpRuntime { requested_timeout, wait_for_server, ) - .await - } - - pub async fn latest_read_resource( - &self, - server: &str, - params: ReadResourceRequestParams, - ) -> anyhow::Result { - self.latest_connections() - .read_resource(server, params) - .await + .await?; + Ok(McpToolCallResult { + result, + source_tool_namespace: namespace, + }) } pub async fn latest_wait_for_server_ready(&self, server: &str, timeout: Duration) -> bool { diff --git a/codex-rs/context-fragments/src/annotated_content.rs b/codex-rs/context-fragments/src/annotated_content.rs index 8f02a9240c..78e8ea8ce9 100644 --- a/codex-rs/context-fragments/src/annotated_content.rs +++ b/codex-rs/context-fragments/src/annotated_content.rs @@ -1,23 +1,37 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::models::ResponseItem; -/// Model-visible content paired with its harness-owned classification. +/// Content, classification and attribution travel together through edits and truncation. #[derive(Clone, Debug, PartialEq)] pub struct AnnotatedContent { content: ContentItem, kind: ContentItemKind, + metadata: ContentItemMetadata, } impl AnnotatedContent { /// Creates content and its classification together. pub fn new(content: ContentItem, kind: ContentItemKind) -> Self { - Self { content, kind } + Self { + content, + kind, + metadata: ContentItemMetadata::default(), + } } - /// Creates model-visible input text and its classification together. - pub fn input_text(text: impl Into, kind: ContentItemKind) -> Self { - Self::new(ContentItem::InputText { text: text.into() }, kind) + /// Creates text with the attribution supplied by its producer. + pub fn text( + text: impl Into, + kind: ContentItemKind, + metadata: ContentItemMetadata, + ) -> Self { + Self { + content: ContentItem::InputText { text: text.into() }, + kind, + metadata, + } } /// Returns the model-visible content. @@ -35,16 +49,29 @@ impl AnnotatedContent { &self.kind } - /// Separates the content from its classification at an API boundary. - pub fn into_parts(self) -> (ContentItem, ContentItemKind) { - (self.content, self.kind) + /// Separates content and its positional annotations at an API boundary. + pub fn into_parts(self) -> (ContentItem, ContentItemKind, ContentItemMetadata) { + (self.content, self.kind, self.metadata) } } -/// Takes a message's content together with its positional classifications. +/// Assembles a message without separating its content from its attribution. +pub fn message_from_parts(role: impl Into, parts: Vec) -> ResponseItem { + let mut item = ResponseItem::Message { + id: None, + role: role.into(), + content: Vec::new(), + phase: None, + internal_chat_message_metadata_passthrough: None, + }; + set_annotated_content(&mut item, parts); + item +} + +/// Takes a message's content together with its classification and source metadata. /// /// Legacy messages, including persisted rollouts, may not have classifications. -/// Missing entries are classified as unknown so the message remains usable. +/// Missing entries have unknown classification and source so the message remains usable. pub fn to_annotated_content(item: &mut ResponseItem) -> Option> { let ResponseItem::Message { content, @@ -59,6 +86,10 @@ pub fn to_annotated_content(item: &mut ResponseItem) -> Option Option, @@ -85,14 +125,22 @@ pub fn set_annotated_content( return None; }; - let (updated_content, content_item_kinds): (Vec<_>, Vec<_>) = annotated_content - .into_iter() - .map(AnnotatedContent::into_parts) - .unzip(); + let mut updated_content = Vec::with_capacity(annotated_content.len()); + let mut content_item_kinds = Vec::with_capacity(annotated_content.len()); + let mut content_item_metadata = Vec::with_capacity(annotated_content.len()); + for annotated in annotated_content { + let (content, kind, metadata) = annotated.into_parts(); + updated_content.push(content); + content_item_kinds.push(kind); + content_item_metadata.push(metadata); + } *content = updated_content; - internal_chat_message_metadata_passthrough - .get_or_insert_default() - .content_item_kinds = Some(content_item_kinds); + let metadata = internal_chat_message_metadata_passthrough.get_or_insert_default(); + metadata.content_item_kinds = Some(content_item_kinds); + metadata.content_item_metadata = content_item_metadata + .iter() + .any(|metadata| metadata != &ContentItemMetadata::default()) + .then_some(content_item_metadata); Some(()) } diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index f01a4a66e4..8d681dddab 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -1,6 +1,7 @@ use crate::AnnotatedContent; +use crate::message_from_parts; use codex_protocol::models::ContentItemKind; -use codex_protocol::models::InternalChatMessageMetadataPassthrough; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::models::ResponseItem; /// A rendered contextual fragment and the role that owns its annotated content. @@ -35,20 +36,7 @@ impl RenderedFragment { impl From for ResponseItem { fn from(fragment: RenderedFragment) -> Self { let (role, annotated_content) = fragment.into_parts(); - let (content, content_kind) = annotated_content.into_parts(); - - Self::Message { - id: None, - role: role.to_string(), - content: vec![content], - phase: None, - internal_chat_message_metadata_passthrough: Some( - InternalChatMessageMetadataPassthrough { - content_item_kinds: Some(vec![content_kind]), - ..Default::default() - }, - ), - } + message_from_parts(role, vec![annotated_content]) } } @@ -64,6 +52,22 @@ impl From for ResponseItem { pub trait ContextualUserFragment { fn role(&self) -> &'static str; + /// Attribution is supplied when the fragment is produced, never inferred from rendered text. + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::harness() + } + + /// Binds producer attribution before this fragment is rendered or persisted. + fn with_metadata(self, metadata: ContentItemMetadata) -> AttributedFragment + where + Self: Sized, + { + AttributedFragment { + fragment: self, + metadata, + } + } + /// Returns a stable `.` classification, using `generic` for shared fragments. fn content_kind(&self) -> ContentItemKind; @@ -97,7 +101,7 @@ pub trait ContextualUserFragment { fn render_fragment(&self) -> RenderedFragment { RenderedFragment::new( self.role(), - AnnotatedContent::input_text(self.render(), self.content_kind()), + AnnotatedContent::text(self.render(), self.content_kind(), self.content_metadata()), ) } @@ -113,6 +117,36 @@ pub trait ContextualUserFragment { } } +/// A contextual fragment with attribution supplied by its producer. +pub struct AttributedFragment { + fragment: F, + metadata: ContentItemMetadata, +} + +impl ContextualUserFragment for AttributedFragment { + fn role(&self) -> &'static str { + self.fragment.role() + } + fn content_kind(&self) -> ContentItemKind { + self.fragment.content_kind() + } + fn markers(&self) -> (&'static str, &'static str) { + self.fragment.markers() + } + fn type_markers() -> (&'static str, &'static str) { + F::type_markers() + } + fn body(&self) -> String { + self.fragment.body() + } + fn render(&self) -> String { + self.fragment.render() + } + fn content_metadata(&self) -> ContentItemMetadata { + self.metadata.clone() + } +} + pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { if start_marker.is_empty() || end_marker.is_empty() { return false; diff --git a/codex-rs/context-fragments/src/lib.rs b/codex-rs/context-fragments/src/lib.rs index 5f2b266cf4..92e087029a 100644 --- a/codex-rs/context-fragments/src/lib.rs +++ b/codex-rs/context-fragments/src/lib.rs @@ -8,8 +8,10 @@ mod recap_prompt; pub use additional_context::AdditionalContextDeveloperFragment; pub use additional_context::AdditionalContextUserFragment; pub use annotated_content::AnnotatedContent; +pub use annotated_content::message_from_parts; pub use annotated_content::set_annotated_content; pub use annotated_content::to_annotated_content; +pub use fragment::AttributedFragment; pub use fragment::ContextualUserFragment; pub use fragment::RenderedFragment; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 895cd26802..8da7b524c1 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -11,6 +11,7 @@ use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; use crate::config::Config; use crate::config::RolloutBudgetConfig; +use crate::context::ContextualUserFragment; use crate::context::SubagentNotification; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::session::emit_subagent_session_started; @@ -32,6 +33,8 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::SubAgentActivityItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::Event; @@ -550,11 +553,27 @@ impl LocalAgentControl { let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; + let config = parent_thread.config().await; + let namespace = match parent_thread + .multi_agent_version() + .unwrap_or_else(|| config.multi_agent_version_from_features()) + { + MultiAgentVersion::Disabled => None, + MultiAgentVersion::V1 => Some(ContentItemNamespace::MultiAgentV1), + MultiAgentVersion::V2 => Some( + config + .multi_agent_v2 + .tool_namespace + .clone() + .map(ContentItemNamespace::from) + .unwrap_or(ContentItemNamespace::Functions), + ), + }; parent_thread - .inject_fragment_without_turn(SubagentNotification::new( - child_reference.as_str(), - status, - )) + .inject_fragment_without_turn( + SubagentNotification::new(child_reference.as_str(), status) + .with_metadata(ContentItemMetadata::tool(namespace)), + ) .await; }; tokio::spawn(async move { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 085a6bd52f..8a7c64f7eb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -996,6 +996,12 @@ impl ModelClient { prefix.push(instructions); } input.splice(0..0, prefix); + if !model_info.use_responses_lite { + // Only RLite accepts per-content attribution until the Responses API schema expands. + for item in &mut input { + item.clear_content_item_metadata(); + } + } if !is_openai { for item in &mut input { item.clear_internal_chat_message_metadata_passthrough(); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8d2cd8351a..df6e64a778 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1209,6 +1209,7 @@ impl CodexThread { /*requested_timeout*/ None, /*wait_for_server*/ true, ) .await + .map(|call| call.result) } pub fn enabled(&self, feature: Feature) -> bool { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f4db71b688..2ec9950e2f 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -716,6 +716,9 @@ fn build_compacted_history_with_limit( content.clone() } else { // Rebuild only the text fallback; never clone discarded media. + if let Some(metadata) = &mut passthrough { + metadata.content_item_metadata = None; + } if let Some(kinds) = passthrough .as_mut() .and_then(|metadata| metadata.content_item_kinds.as_mut()) diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index cc6772d8f9..5c564fb960 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -171,6 +171,7 @@ fn detects_hook_prompt_fragment_and_roundtrips_escaping() { vec![HookPromptFragment { text: r#"Retry with "waves" & "#.to_string(), hook_run_id: "hook-run-1".to_string(), + metadata: Default::default(), }], ); assert!(!text.contains(""waves" & ")); diff --git a/codex-rs/core/src/context/current_time_reminder.rs b/codex-rs/core/src/context/current_time_reminder.rs index 2273cbc04c..4eb7ff43a8 100644 --- a/codex-rs/core/src/context/current_time_reminder.rs +++ b/codex-rs/core/src/context/current_time_reminder.rs @@ -3,6 +3,8 @@ use chrono::Utc; use super::ContextualUserFragment; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; pub(crate) struct CurrentTimeReminder { current_time: DateTime, @@ -21,6 +23,10 @@ impl CurrentTimeReminder { } impl ContextualUserFragment for CurrentTimeReminder { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::tool(Some(ContentItemNamespace::Clock)) + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("current_time.reminder".to_string()) } @@ -49,6 +55,10 @@ impl CurrentTimeUnavailable { } impl ContextualUserFragment for CurrentTimeUnavailable { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::tool(Some(ContentItemNamespace::Clock)) + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("current_time.unavailable".to_string()) } diff --git a/codex-rs/core/src/context/developer_instructions.rs b/codex-rs/core/src/context/developer_instructions.rs index 6ccb83e85a..bd42a600fc 100644 --- a/codex-rs/core/src/context/developer_instructions.rs +++ b/codex-rs/core/src/context/developer_instructions.rs @@ -1,5 +1,6 @@ use super::ContextualUserFragment; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DeveloperInstructions { @@ -15,6 +16,10 @@ impl DeveloperInstructions { } impl ContextualUserFragment for DeveloperInstructions { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::developer_instructions(/*from_additional_requirements*/ false) + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("generic.developer_instructions".to_string()) } diff --git a/codex-rs/core/src/context/hook_additional_context.rs b/codex-rs/core/src/context/hook_additional_context.rs index 9fe2bcc299..d3117c2e9d 100644 --- a/codex-rs/core/src/context/hook_additional_context.rs +++ b/codex-rs/core/src/context/hook_additional_context.rs @@ -1,14 +1,16 @@ use super::ContextualUserFragment; +use codex_hooks::HookContext; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; #[derive(Debug, Clone, PartialEq)] pub(crate) struct HookAdditionalContext { - text: String, + context: HookContext, } impl HookAdditionalContext { - pub(crate) fn new(text: impl Into) -> Self { - Self { text: text.into() } + pub(crate) fn new(context: HookContext) -> Self { + Self { context } } } @@ -30,6 +32,10 @@ impl ContextualUserFragment for HookAdditionalContext { } fn body(&self) -> String { - self.text.clone() + self.context.text.clone() + } + + fn content_metadata(&self) -> ContentItemMetadata { + self.context.metadata.clone() } } diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index dd984505b0..04fb4cb549 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -3,8 +3,12 @@ use super::world_state::PreviousSectionState; use super::world_state::WorldStateSection; use crate::context::world_state::SectionTransition; use crate::context::world_state::WorldStateUpdate; +use codex_context_fragments::AnnotatedContent; +use codex_context_fragments::message_from_parts; use codex_protocol::AgentPath; +use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; @@ -17,7 +21,6 @@ pub(crate) struct TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - thread_hint: Option, } impl TokenBudgetContext { @@ -26,16 +29,39 @@ impl TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - thread_hint: Option, ) -> Self { Self { agent_path, first_window_id, previous_window_id, window_id, - thread_hint, } } + + /// Wraps tool hints without losing their source or changing the concatenated prompt text. + pub(crate) fn render_with_hints(&self, hints: Vec) -> ResponseItem { + if hints.is_empty() { + return ContextualUserFragment::into(self.clone()); + } + let mut content = vec![AnnotatedContent::text( + format!("{CONTEXT_WINDOW_OPEN_TAG}{}", self.body()), + self.content_kind(), + self.content_metadata(), + )]; + for mut part in hints { + let ContentItem::InputText { text } = part.content_mut() else { + unreachable!("context-window hints are text prompt fragments"); + }; + text.push('\n'); + content.push(part); + } + content.push(AnnotatedContent::text( + CONTEXT_WINDOW_CLOSE_TAG, + self.content_kind(), + self.content_metadata(), + )); + message_from_parts(self.role(), content) + } } impl ContextualUserFragment for TokenBudgetContext { @@ -66,9 +92,6 @@ impl ContextualUserFragment for TokenBudgetContext { if let Some(previous_window_id) = self.previous_window_id { lines.push(format!("Previous context window id: {previous_window_id}")); } - if let Some(thread_hint) = &self.thread_hint { - lines.push(thread_hint.clone()); - } format!("\n{}\n", lines.join("\n")) } } diff --git a/codex-rs/core/src/context/user_instructions.rs b/codex-rs/core/src/context/user_instructions.rs index f75cb24d57..2ceecbf7c5 100644 --- a/codex-rs/core/src/context/user_instructions.rs +++ b/codex-rs/core/src/context/user_instructions.rs @@ -1,5 +1,6 @@ use super::ContextualUserFragment; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; #[derive(Debug, Clone, PartialEq)] pub(crate) struct UserInstructions { @@ -8,6 +9,10 @@ pub(crate) struct UserInstructions { } impl ContextualUserFragment for UserInstructions { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::agents_md() + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("agents_md.instructions".to_string()) } diff --git a/codex-rs/core/src/context/world_state/managed_developer_instructions.rs b/codex-rs/core/src/context/world_state/managed_developer_instructions.rs index e59bcef834..be5bc85f5f 100644 --- a/codex-rs/core/src/context/world_state/managed_developer_instructions.rs +++ b/codex-rs/core/src/context/world_state/managed_developer_instructions.rs @@ -6,6 +6,7 @@ use super::WorldStateUpdate; use crate::context::ContextualUserFragment; use codex_config::Sourced; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_utils_string::approx_bytes_for_tokens; use codex_utils_string::approx_tokens_from_byte_count; use serde::Deserialize; @@ -24,6 +25,10 @@ pub(crate) struct ManagedDeveloperInstructions { } impl ContextualUserFragment for ManagedDeveloperInstructions { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::developer_instructions(/*from_additional_requirements*/ true) + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("managed_config.developer_instructions".to_string()) } diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 6a5d22db80..745ac761bc 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -277,6 +277,10 @@ struct WorldStateContextFragment { } impl ContextualUserFragment for WorldStateContextFragment { + fn content_metadata(&self) -> codex_protocol::models::ContentItemMetadata { + self.fragment.metadata().clone() + } + fn content_kind(&self) -> ContentItemKind { self.content_kind.clone() } diff --git a/codex-rs/core/src/context/world_state/model_catalog.rs b/codex-rs/core/src/context/world_state/model_catalog.rs index b46c9bf774..3f768b3b21 100644 --- a/codex-rs/core/src/context/world_state/model_catalog.rs +++ b/codex-rs/core/src/context/world_state/model_catalog.rs @@ -12,6 +12,8 @@ use crate::agent::child_config::model_supports_multi_agent_backend; use crate::context::ContextualUserFragment; use crate::context::environment_context::push_xml_escaped_text; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::MultiAgentVersion; @@ -24,6 +26,7 @@ const OMITTED_NOTICE: &str = "Additional model choices omitted.\n"; #[derive(Clone, Default)] pub(crate) struct ModelCatalogState { catalog: String, + namespace: Option, } impl ModelCatalogState { @@ -82,11 +85,23 @@ impl ModelCatalogState { catalog.push_str(&escaped); catalog.push('\n'); } - Self { catalog } + Self { + catalog, + namespace: None, + } + } + + pub(crate) fn with_namespace(mut self, namespace: Option) -> Self { + self.namespace = namespace; + self } } impl ContextualUserFragment for ModelCatalogState { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::tool(self.namespace.clone()) + } + fn content_kind(&self) -> ContentItemKind { ContentItemKind("multi_agent.model_catalog".to_string()) } diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode.rs b/codex-rs/core/src/context/world_state/multi_agent_mode.rs index 4a575da4d3..3ff875db45 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode.rs @@ -7,6 +7,8 @@ use super::multi_agent_usage_hint::MultiAgentUsageHintState; use crate::context::ContextualUserFragment; use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions; use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; use serde::Deserialize; @@ -20,6 +22,8 @@ pub(crate) struct MultiAgentModeState { mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] usage_hint_hash: Option, + #[serde(skip)] + namespace: Option, } impl MultiAgentModeState { @@ -33,9 +37,15 @@ impl MultiAgentModeState { mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode, }), usage_hint_hash: None, + namespace: None, } } + pub(crate) fn with_namespace(mut self, namespace: ContentItemNamespace) -> Self { + self.namespace = Some(namespace); + self + } + pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self { self.usage_hint_hash = Some(usage_hint.fingerprint.clone()); self @@ -81,8 +91,10 @@ impl WorldStateSection for MultiAgentModeState { } }; - let fragment = MultiAgentModeInstructions::from_mode(mode) - .map(|instructions| Box::new(instructions) as Box); + let fragment = MultiAgentModeInstructions::from_mode(mode).map(|instructions| { + Box::new(instructions.with_metadata(ContentItemMetadata::tool(self.namespace.clone()))) + as Box + }); ( Some(self.clone()), WorldStateUpdate::optional_boxed_fragment(fragment), diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs index 041b765459..4897929f16 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs @@ -136,8 +136,12 @@ fn catalog_role_updates_remain_separate_from_active_mode() { assert_eq!( updates, vec![ - ContextualUserFragment::into(current_role), - ContextualUserFragment::into(expected_mode), + ContextualUserFragment::into(current_role.with_metadata( + codex_protocol::models::ContentItemMetadata::tool(/*namespace*/ None) + )), + ContextualUserFragment::into(expected_mode.with_metadata( + codex_protocol::models::ContentItemMetadata::tool(/*namespace*/ None) + )), ], ); } diff --git a/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs index 42892220ca..77d65dc277 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs @@ -6,12 +6,15 @@ use super::WorldStateUpdate; use crate::context::ContextualUserFragment; use crate::context::MultiAgentRoleInstructions; use crate::context::MultiAgentUsageHint; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; /// Configured or model-owned multi-agent instructions currently visible to the model. #[derive(Clone, Debug)] pub(crate) struct MultiAgentUsageHintState { instructions: MultiAgentRoleInstructions, pub(super) fingerprint: WorldStateHash, + namespace: Option, } impl MultiAgentUsageHintState { @@ -20,8 +23,14 @@ impl MultiAgentUsageHintState { Self { instructions, fingerprint, + namespace: None, } } + + pub(crate) fn with_namespace(mut self, namespace: ContentItemNamespace) -> Self { + self.namespace = Some(namespace); + self + } } impl WorldStateSection for MultiAgentUsageHintState { @@ -43,11 +52,14 @@ impl WorldStateSection for MultiAgentUsageHintState { PreviousSectionState::Unknown => None, PreviousSectionState::Known(_) | PreviousSectionState::Absent => { if self.instructions.markers().0.is_empty() { - Some(Box::new(MultiAgentUsageHint::new( - &self.instructions.body(), - ))) + Some(Box::new( + MultiAgentUsageHint::new(&self.instructions.body()) + .with_metadata(ContentItemMetadata::tool(self.namespace.clone())), + )) } else { - Some(Box::new(self.instructions.clone())) + Some(Box::new(self.instructions.clone().with_metadata( + ContentItemMetadata::tool(self.namespace.clone()), + ))) } } }; diff --git a/codex-rs/core/src/context/world_state/tools.rs b/codex-rs/core/src/context/world_state/tools.rs index 74582ab77d..de2757e07d 100644 --- a/codex-rs/core/src/context/world_state/tools.rs +++ b/codex-rs/core/src/context/world_state/tools.rs @@ -8,12 +8,15 @@ use super::SectionTransition; use super::WorldStateContextFragment; use super::WorldStateSection; use super::WorldStateUpdate; +use crate::context::ContextualUserFragment; use codex_extension_api::ExtensionMetrics; use codex_extension_api::RenderedWorldStateFragment; use codex_otel::THREAD_TOOLS_FRAGMENT_BYTES_METRIC; use codex_otel::THREAD_TOOLS_METRIC_BUCKETS; use codex_otel::THREAD_TOOLS_NAMESPACES_TOTAL_METRIC; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; use codex_protocol::protocol::TOOLS_CLOSE_TAG; use codex_protocol::protocol::TOOLS_OPEN_TAG; use std::collections::BTreeMap; @@ -138,14 +141,19 @@ impl WorldStateSection for ToolsState { record_fragment_metrics(self.metrics.as_ref(), previous, &rendered); ( Some(current), - vec![WorldStateUpdate::fragment(WorldStateContextFragment { - fragment: RenderedWorldStateFragment::new( - "developer", - (TOOLS_OPEN_TAG, TOOLS_CLOSE_TAG), - rendered.body, - ), - content_kind: ContentItemKind("tools.deferred_namespaces".to_string()), - })], + vec![WorldStateUpdate::fragment( + WorldStateContextFragment { + fragment: RenderedWorldStateFragment::new( + "developer", + (TOOLS_OPEN_TAG, TOOLS_CLOSE_TAG), + rendered.body, + ), + content_kind: ContentItemKind("tools.deferred_namespaces".to_string()), + } + .with_metadata(ContentItemMetadata::tool(Some( + ContentItemNamespace::ToolSearch, + ))), + )], ) } } diff --git a/codex-rs/core/src/context/world_state/top_level_tools.rs b/codex-rs/core/src/context/world_state/top_level_tools.rs index cc07cef8f5..21f7c2cdb6 100644 --- a/codex-rs/core/src/context/world_state/top_level_tools.rs +++ b/codex-rs/core/src/context/world_state/top_level_tools.rs @@ -16,6 +16,7 @@ use codex_otel::TOOL_INCREMENTAL_UPDATES_METRIC; use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::Result; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::models::ResponseItem; use serde_json::Value; use std::collections::BTreeMap; @@ -187,8 +188,10 @@ impl WorldStateSection for TopLevelToolsState { } else { format!("Updated instructions for the {name} namespace:\n{instructions}") }; - namespace_updates - .push(WorldStateUpdate::fragment(DeveloperInstructions::new(text))); + namespace_updates.push(WorldStateUpdate::fragment( + DeveloperInstructions::new(text) + .with_metadata(ContentItemMetadata::tool(Some(name.to_string().into()))), + )); } } let mut updates = Vec::new(); diff --git a/codex-rs/core/src/context/world_state/top_level_tools_tests.rs b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs index a072b039af..419d7ca213 100644 --- a/codex-rs/core/src/context/world_state/top_level_tools_tests.rs +++ b/codex-rs/core/src/context/world_state/top_level_tools_tests.rs @@ -304,9 +304,14 @@ fn namespace_description_change_does_not_repeat_unchanged_tools() { let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot)); assert_eq!( merge_world_state_updates(updates), - vec![ContextualUserFragment::into(DeveloperInstructions::new( - "Updated instructions for the functions namespace:\nUpdated namespace guidance." - ))] + vec![ContextualUserFragment::into( + DeveloperInstructions::new( + "Updated instructions for the functions namespace:\nUpdated namespace guidance." + ) + .with_metadata(codex_protocol::models::ContentItemMetadata::tool(Some( + codex_protocol::models::ContentItemNamespace::Functions + ))) + )] ); assert!( current diff --git a/codex-rs/core/src/context/world_state/world_state_tests.rs b/codex-rs/core/src/context/world_state/world_state_tests.rs index a5ceddc775..aef284e161 100644 --- a/codex-rs/core/src/context/world_state/world_state_tests.rs +++ b/codex-rs/core/src/context/world_state/world_state_tests.rs @@ -192,11 +192,14 @@ fn extension_owned_section_uses_its_stable_id_as_content_kind_feature() { world_state.add_extension_section(WorldStateSectionContribution::new("extension_test", |_| { ( Some(json!({"value": "after"})), - Some(RenderedWorldStateFragment::new( - "developer", - ("", ""), - "after", - )), + Some( + RenderedWorldStateFragment::new( + "developer", + ("", ""), + "after", + ) + .with_metadata(codex_protocol::models::ContentItemMetadata::skills()), + ), ) })); @@ -211,9 +214,10 @@ fn extension_owned_section_uses_its_stable_id_as_content_kind_feature() { .collect::>(), vec![( "developer", - AnnotatedContent::input_text( + AnnotatedContent::text( "after", ContentItemKind("extension_test.instructions".to_string()), + codex_protocol::models::ContentItemMetadata::skills(), ), )] ); diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index c5551b6320..a999fbe5d9 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -46,6 +46,8 @@ use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; +use serde_json::from_value; +use serde_json::json; use test_case::test_case; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; @@ -1264,6 +1266,10 @@ fn for_prompt_strips_media_when_model_does_not_support_it() { ContentItemKind("audio.unsupported".to_string()), ContentItemKind("user.text".to_string()), ]), + content_item_metadata: from_value(json!([ + {}, {"harness_injected": true}, {"harness_injected": true}, {} + ])) + .unwrap(), ..Default::default() }, ), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 576d546849..fa3ded9147 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -4,27 +4,18 @@ use crate::context::world_state::Placement; use crate::context::world_state::WorldStateUpdate; use crate::context::world_state::WorldStateUpdateContent; use codex_context_fragments::RenderedFragment; -use codex_protocol::models::InternalChatMessageMetadataPassthrough; +use codex_context_fragments::message_from_parts; use codex_protocol::models::ResponseItem; pub(crate) fn build_rendered_message(fragments: Vec) -> Option { let role = fragments.first()?.role(); debug_assert!(fragments.iter().all(|fragment| fragment.role() == role)); - let (content, content_item_kinds): (Vec<_>, Vec<_>) = fragments + let content = fragments .into_iter() - .map(|fragment| fragment.into_parts().1.into_parts()) - .unzip(); + .map(|fragment| fragment.into_parts().1) + .collect(); - Some(ResponseItem::Message { - id: None, - role: role.to_string(), - content, - phase: None, - internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { - content_item_kinds: Some(content_item_kinds), - ..Default::default() - }), - }) + Some(message_from_parts(role, content)) } pub(crate) fn merge_world_state_updates(updates: Vec) -> Vec { diff --git a/codex-rs/core/src/context_manager/updates_tests.rs b/codex-rs/core/src/context_manager/updates_tests.rs index 026058bbdf..94c249fee1 100644 --- a/codex-rs/core/src/context_manager/updates_tests.rs +++ b/codex-rs/core/src/context_manager/updates_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::context::ContextualUserFragment; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; use serde_json::json; @@ -44,6 +45,7 @@ fn fragment(role: &'static str, text: &'static str) -> TestFragment { } fn message(role: &str, texts: &[&str]) -> ResponseItem { + let provenance = serde_json::from_value(json!({"harness_injected": true})).unwrap(); ResponseItem::Message { id: None, role: role.to_string(), @@ -55,6 +57,7 @@ fn message(role: &str, texts: &[&str]) -> ResponseItem { .collect(), phase: None, internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { + content_item_metadata: Some(vec![provenance; texts.len()]), content_item_kinds: Some( texts .iter() diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index d6d9f59f2c..e60e1e9ddd 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -429,6 +429,7 @@ fn parses_hook_prompt_message_as_distinct_turn_item() { HookPromptFragment { text: "Retry with exactly the phrase meow meow meow.".to_string(), hook_run_id: "hook-run-1".to_string(), + metadata: Default::default(), } ); } @@ -464,6 +465,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { vec![HookPromptFragment { text: "Retry with care & joy.".to_string(), hook_run_id: "hook-run-1".to_string(), + metadata: Default::default(), }] ); } diff --git a/codex-rs/core/src/hook_mcp_executor.rs b/codex-rs/core/src/hook_mcp_executor.rs index 7fa0b78d42..279da61394 100644 --- a/codex-rs/core/src/hook_mcp_executor.rs +++ b/codex-rs/core/src/hook_mcp_executor.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::bail; use codex_hooks::HookMcpCall; use codex_hooks::HookMcpExecutor; +use codex_hooks::HookMcpOutput; use codex_mcp::McpRuntime; use codex_protocol::ThreadId; use futures::FutureExt; @@ -16,7 +17,7 @@ pub(crate) struct CoreHookMcpExecutor { } impl HookMcpExecutor for CoreHookMcpExecutor { - fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { + fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { async move { let mut metadata = call.metadata.unwrap_or_default(); metadata.insert( @@ -24,7 +25,7 @@ impl HookMcpExecutor for CoreHookMcpExecutor { Value::String(self.thread_id.to_string()), ); - let result = self + let call_result = self .runtime .latest_call_tool( &call.server, @@ -36,6 +37,7 @@ impl HookMcpExecutor for CoreHookMcpExecutor { /*wait_for_server*/ false, ) .await?; + let result = call_result.result; let text = result .content .iter() @@ -50,7 +52,10 @@ impl HookMcpExecutor for CoreHookMcpExecutor { bail!("MCP tool returned an error: {text}"); } - Ok(text) + Ok(HookMcpOutput { + text, + source_tool_namespace: call_result.source_tool_namespace, + }) } .boxed() } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 30c4162ffc..9a4c86b11e 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -75,7 +75,7 @@ use crate::turn_metadata::ExecutionMetadata; pub(crate) struct HookRuntimeOutcome { pub should_stop: bool, - pub additional_contexts: Vec, + pub additional_contexts: Vec, } pub(crate) enum PreToolUseHookResult { @@ -718,7 +718,7 @@ pub(crate) async fn record_pending_input( turn_context: &Arc, model_info: &ModelInfo, pending_input: TurnInput, - additional_contexts: Vec, + additional_contexts: Vec, persist_context: PersistContext, ) { match pending_input { @@ -790,7 +790,10 @@ pub(crate) async fn drain_async_hook_results( .entries .iter() .filter(|entry| entry.kind == HookOutputEntryKind::Context) - .map(|entry| entry.text.clone()) + .map(|entry| codex_hooks::HookContext { + text: entry.text.clone(), + metadata: codex_protocol::models::ContentItemMetadata::command_hook(), + }) .collect::>(); if before_user_prompt { @@ -849,7 +852,7 @@ impl HookRuntimeOutcome { pub(crate) async fn record_additional_contexts( sess: &Arc, turn_context: &Arc, - additional_contexts: Vec, + additional_contexts: Vec, ) { let developer_messages = additional_context_messages(additional_contexts); if developer_messages.is_empty() { @@ -864,7 +867,9 @@ pub(crate) async fn record_additional_contexts( .await; } -fn additional_context_messages(additional_contexts: Vec) -> Vec { +fn additional_context_messages( + additional_contexts: Vec, +) -> Vec { additional_contexts .into_iter() .map(HookAdditionalContext::new) @@ -1104,8 +1109,8 @@ mod tests { #[test] fn additional_context_messages_stay_separate_and_ordered() { let messages = additional_context_messages(vec![ - "first tide note".to_string(), - "second tide note".to_string(), + codex_hooks::HookContext::harness("first tide note".to_string()), + codex_hooks::HookContext::harness("second tide note".to_string()), ]); assert_eq!(messages.len(), 2); diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index e3be1a0612..ae7e97f9db 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -283,9 +283,10 @@ async fn prepare_message_content( Ok(_) => {} Err(error) => { warn!(%error, "failed to prepare message image"); - *item = AnnotatedContent::input_text( + *item = AnnotatedContent::text( error.placeholder(), ContentItemKind("images.preparation_error".to_string()), + codex_protocol::models::ContentItemMetadata::harness(), ); } } diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 3bee21f152..d399b282b6 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -20,6 +20,8 @@ use image::ImageBuffer; use image::ImageFormat; use image::Rgba; use pretty_assertions::assert_eq; +use serde_json::from_value; +use serde_json::json; use super::*; @@ -138,6 +140,7 @@ async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { content_item_kinds: Some(vec![ContentItemKind( "images.preparation_error".to_string() )]), + content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(), ..Default::default() } ), @@ -413,6 +416,8 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() { ContentItemKind("images.preparation_error".to_string()), ContentItemKind("user.image".to_string()), ]), + content_item_metadata: from_value(json!([{}, {}, {"harness_injected": true}, {}])) + .unwrap(), ..Default::default() }), ); @@ -467,6 +472,7 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() { content_item_kinds: Some(vec![ContentItemKind( "images.resize_notice".to_string() )]), + content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(), ..Default::default() }, ), @@ -520,6 +526,7 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() { content_item_kinds: Some(vec![ContentItemKind( "images.resize_notice".to_string() )]), + content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(), ..Default::default() }, ), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0c667d1be5..2b5baf264c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -130,6 +130,8 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; +use codex_protocol::models::ContentItemNamespace; use codex_protocol::models::ImageReference; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::PermissionProfile; @@ -3514,6 +3516,7 @@ impl Session { *internal_chat_message_metadata_passthrough = Some(InternalChatMessageMetadataPassthrough { content_item_kinds: Some(content_item_kinds), + content_item_metadata: Some(vec![ContentItemMetadata::user(); content.len()]), ..Default::default() }); } @@ -3949,7 +3952,31 @@ impl Session { model_info: &ModelInfo, communication: InterAgentCommunication, ) { - let response_item = communication.to_model_input_item(); + let mut response_item = communication.to_model_input_item(); + if let ResponseItem::AgentMessage { + content, + internal_chat_message_metadata_passthrough, + .. + } = &mut response_item + { + internal_chat_message_metadata_passthrough + .get_or_insert_default() + .content_item_metadata + .get_or_insert_with(|| { + vec![ + ContentItemMetadata::tool(Some( + turn_context + .config + .multi_agent_v2 + .tool_namespace + .clone() + .map(ContentItemNamespace::from) + .unwrap_or(ContentItemNamespace::Functions), + )); + content.len() + ] + }); + } let (items, _) = self .prepare_conversation_items_for_history( turn_context, @@ -4275,7 +4302,7 @@ impl Session { let turn_context = step_context.turn.as_ref(); let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); - let mut separate_developer_sections = Vec::::new(); + let mut separate_developer_messages = Vec::::new(); let mut context_window_hints = Vec::new(); let (session_source, auto_compact_window_ids) = { let state = self.state.lock().await; @@ -4332,7 +4359,7 @@ impl Session { { match fragment.slot() { PromptSlot::ContextWindow => { - context_window_hints.push(fragment.text().to_string()); + context_window_hints.push(fragment.into_content()); } PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => { developer_sections.push(fragment.into()); @@ -4370,7 +4397,7 @@ impl Session { .token_budget .as_ref() .is_some_and(|config| config.use_history_notes_extension) - && let Some(mcp_result) = self + && let Some((text, source_tool_namespace)) = self .services .mcp_runtime .latest_call_tool( @@ -4386,8 +4413,9 @@ impl Session { ) .await .ok() - .and_then(|result| { - let text = result + .and_then(|call| { + let text = call + .result .content .iter() .filter_map(|content| { @@ -4396,12 +4424,16 @@ impl Session { .filter(|text| !text.is_empty()) .collect::>() .join("\n"); - (!text.is_empty()).then_some(text) + (!text.is_empty()).then_some((text, call.source_tool_namespace)) }) { - context_window_hints.push(mcp_result); + context_window_hints.push(codex_context_fragments::AnnotatedContent::text( + text, + ContentItemKind("notes.thread_hint".to_string()), + ContentItemMetadata::tool(source_tool_namespace.map(Into::into)), + )); } - separate_developer_sections.push( + separate_developer_messages.push( crate::context::TokenBudgetContext::new( session_source .get_agent_path() @@ -4409,9 +4441,8 @@ impl Session { auto_compact_window_ids.first_window_id, auto_compact_window_ids.previous_window_id, auto_compact_window_ids.window_id, - (!context_window_hints.is_empty()).then(|| context_window_hints.join("\n")), ) - .render_fragment(), + .render_with_hints(context_window_hints), ); } let (snapshot, updates) = world_state.render_full(); @@ -4451,13 +4482,13 @@ impl Session { "developer" if fragment.markers().0 == MultiAgentRoleInstructions::type_markers().0 => { - separate_developer_sections.push(fragment.render_fragment()); + separate_developer_messages.push(fragment.render_fragment().into()); } "developer" if update.placement == Placement::Standalone && fragment.markers().0.is_empty() => { - separate_developer_sections.push(fragment.render_fragment()); + separate_developer_messages.push(fragment.render_fragment().into()); } "developer" => developer_sections.push(fragment.render_fragment()), "user" => contextual_user_sections.push(fragment.render_fragment()), @@ -4478,13 +4509,7 @@ impl Session { ) { items.push(developer_message); } - for section in separate_developer_sections.drain(..) { - if let Some(developer_message) = - crate::context_manager::updates::build_rendered_message(vec![section]) - { - items.push(developer_message); - } - } + items.append(&mut separate_developer_messages); if let Some(initial_multi_agent_mode) = initial_multi_agent_mode && let Some(message) = crate::context_manager::updates::build_rendered_message(vec![ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e143e1094a..07739aa4cb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -161,6 +161,7 @@ use codex_protocol::mcp::McpAttributionStatus; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; @@ -2768,6 +2769,11 @@ async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { /*trigger_turn*/ false, ); let mut expected_item = communication.to_model_input_item(); + let mut expected = serde_json::to_value(&expected_item).unwrap(); + expected["internal_chat_message_metadata_passthrough"] = json!({ + "content_item_metadata": [{"harness_injected": true, "source_tool_namespace": "collaboration"}], + }); + expected_item = serde_json::from_value(expected).unwrap(); expected_item.set_turn_id_if_missing(&turn_context.sub_id); session @@ -3155,6 +3161,11 @@ async fn prepares_resumed_history_before_installing_it() { ContentItemKind("images.preparation_error".to_string()), ContentItemKind("unknown".to_string()), ]), + content_item_metadata: Some(vec![ + ContentItemMetadata::harness(), + ContentItemMetadata::harness(), + ContentItemMetadata::default(), + ]), ..Default::default() }, ), @@ -4123,6 +4134,9 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item content_item_kinds: Some(vec![ContentItemKind( "generic.developer_instructions".to_string(), )]), + content_item_metadata: Some(vec![ContentItemMetadata::developer_instructions( + /*from_additional_requirements*/ false, + )]), ..Default::default() }), }; diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index a556d20a4c..a23dea125c 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -34,6 +34,7 @@ use codex_prompts::ResolvedModelMessages; use codex_prompts::render_model_instructions; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::BaseInstructionsProvenance; +use codex_protocol::models::ContentItemNamespace; use codex_protocol::protocol::MultiAgentVersion; use codex_tools::ToolName; @@ -173,7 +174,6 @@ impl Session { window_ids.first_window_id, window_ids.previous_window_id, window_ids.window_id, - /*thread_hint*/ None, )); } let guidance = step_context @@ -354,11 +354,20 @@ impl Session { world_state.add_extension_section(section); } } + let v2_namespace = turn_context + .config + .multi_agent_v2 + .tool_namespace + .clone() + .map(ContentItemNamespace::from) + .unwrap_or(ContentItemNamespace::Functions); let mut multi_agent_mode = MultiAgentModeState::new( super::multi_agents::effective_multi_agent_mode(step_context), - ); + ) + .with_namespace(v2_namespace.clone()); if let Some(usage_hint_text) = super::multi_agents::usage_hint_text(step_context) { - let usage_hint = MultiAgentUsageHintState::new(usage_hint_text); + let usage_hint = + MultiAgentUsageHintState::new(usage_hint_text).with_namespace(v2_namespace.clone()); multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint); world_state.add_section(usage_hint); } @@ -395,7 +404,12 @@ impl Session { ) } else { ModelCatalogState::default() - }, + } + .with_namespace(match turn_context.multi_agent_version { + MultiAgentVersion::Disabled => None, + MultiAgentVersion::V1 => Some(ContentItemNamespace::MultiAgentV1), + MultiAgentVersion::V2 => Some(v2_namespace), + }), ); if !crate::guardian::is_basic_session_source(&turn_context.session_source) { world_state.add_section(ManagedDeveloperInstructionsState::new( diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c9c6fdea1b..35944175a8 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -27,6 +27,7 @@ use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID; use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; @@ -2939,6 +2940,7 @@ fn multi_agent_v2_interrupted_marker_uses_developer_input_message() { content_item_kinds: Some(vec![ContentItemKind( "generic.turn_aborted".to_string() )]), + content_item_metadata: Some(vec![ContentItemMetadata::harness()]), ..Default::default() } ), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b46bc107e9..6b025e6108 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5787,6 +5787,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_ )?; let mut builder = test_codex() .with_home(Arc::clone(&home)) + .with_model_info_override("gpt-5.5", |model| model.use_responses_lite = true) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let test = builder.build(&server).await?; @@ -5846,6 +5847,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_ let resumed_cwd = test.config.cwd.clone(); let mut resume_builder = test_codex() .with_home(Arc::clone(&home)) + .with_model_info_override("gpt-5.5", |model| model.use_responses_lite = true) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| config.cwd = resumed_cwd); let resumed = resume_builder @@ -5861,7 +5863,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_ assert_single_instruction_fragment(&requests[3], &new_fragment); let resumed_input = requests[3].input(); assert_eq!( - resumed_input.get(1..=replacement_history.len()), + resumed_input.get(2..=replacement_history.len() + 1), Some(replacement_history.as_slice()), "remote-v2 cold resume should replay persisted replacement history verbatim" ); diff --git a/codex-rs/core/tests/suite/mcp_auth_refresh.rs b/codex-rs/core/tests/suite/mcp_auth_refresh.rs index 67bfe7f09b..03d42e2707 100644 --- a/codex-rs/core/tests/suite/mcp_auth_refresh.rs +++ b/codex-rs/core/tests/suite/mcp_auth_refresh.rs @@ -143,7 +143,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token( /*wait_for_server*/ true, ) .await?; - assert_eq!(tool_result.is_error, Some(false)); + assert_eq!(tool_result.result.is_error, Some(false)); let requests = server .received_requests() diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index d109c4154c..dde83441f3 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -133,21 +133,23 @@ async fn base_instructions_use_input_items(responses_lite: bool) -> Result<()> { .as_str() .is_some_and(|id| id.starts_with("msg_")) ); - assert_eq!( - instructions, - &serde_json::json!({ - "id": instructions["id"], - "type": "message", - "role": "developer", - "content": [{ - "type": "input_text", - "text": "test instructions", - }], - "internal_chat_message_metadata_passthrough": { - "content_item_kinds": ["model.base_instructions"], - }, - }) - ); + let mut expected_instructions = serde_json::json!({ + "id": instructions["id"], + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "test instructions", + }], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["model.base_instructions"], + }, + }); + if responses_lite { + expected_instructions["internal_chat_message_metadata_passthrough"]["content_item_metadata"] = + serde_json::json!([{"provenance": {"type": "harness", "hook": false}}]); + } + assert_eq!(instructions, &expected_instructions); let tools = if responses_lite { assert!(body.get("tools").is_none()); diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index a0b23a1db7..23fae98781 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -76,8 +76,9 @@ fn model_token_budget_config() -> ModelTokenBudgetConfig { fn token_budget_contexts(request: &ResponsesRequest) -> Vec { let context_window_prefix = format!("{CONTEXT_WINDOW_OPEN_TAG}\n"); request - .message_input_texts("developer") + .message_input_text_groups("developer") .into_iter() + .map(|parts| parts.concat()) .filter(|text| text.starts_with(&context_window_prefix)) .collect() } diff --git a/codex-rs/ext/extension-api/src/contributors/prompt.rs b/codex-rs/ext/extension-api/src/contributors/prompt.rs index 73a12e3a6a..e2dd3ae438 100644 --- a/codex-rs/ext/extension-api/src/contributors/prompt.rs +++ b/codex-rs/ext/extension-api/src/contributors/prompt.rs @@ -1,8 +1,10 @@ -// All this file should be replaced by the existing fragment implementation ofc +//! Extension prompt placement with content and producer attribution kept together. use codex_context_fragments::AnnotatedContent; use codex_context_fragments::RenderedFragment; +use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum PromptSlot { @@ -12,20 +14,35 @@ pub enum PromptSlot { ContextWindow, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PromptFragment { slot: PromptSlot, - text: String, - content_kind: ContentItemKind, + content: AnnotatedContent, } impl PromptFragment { - /// Creates a prompt fragment for the given slot. + /// Creates harness-provided context for the given slot. pub fn new(slot: PromptSlot, text: impl Into, content_kind: ContentItemKind) -> Self { Self { slot, - text: text.into(), - content_kind, + content: AnnotatedContent::text(text, content_kind, ContentItemMetadata::harness()), + } + } + + /// Creates tool-supplied context without changing its prompt placement. + pub fn tool( + slot: PromptSlot, + text: impl Into, + content_kind: ContentItemKind, + namespace: String, + ) -> Self { + Self { + slot, + content: AnnotatedContent::text( + text, + content_kind, + ContentItemMetadata::tool(Some(namespace.into())), + ), } } @@ -46,20 +63,25 @@ impl PromptFragment { /// Returns the model-visible text. pub fn text(&self) -> &str { - &self.text + let ContentItem::InputText { text } = self.content.content() else { + unreachable!("prompt fragments contain text"); + }; + text } /// Returns the producer-owned classification of the model-visible text. pub fn content_kind(&self) -> &ContentItemKind { - &self.content_kind + self.content.kind() + } + + /// Consumes the placement wrapper when assembling a shared context message. + pub fn into_content(self) -> AnnotatedContent { + self.content } } impl From for RenderedFragment { fn from(fragment: PromptFragment) -> Self { - Self::new( - "developer", - AnnotatedContent::input_text(fragment.text, fragment.content_kind), - ) + Self::new("developer", fragment.content) } } diff --git a/codex-rs/ext/extension-api/src/contributors/world_state.rs b/codex-rs/ext/extension-api/src/contributors/world_state.rs index f5571bb8d8..5c51a82025 100644 --- a/codex-rs/ext/extension-api/src/contributors/world_state.rs +++ b/codex-rs/ext/extension-api/src/contributors/world_state.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_exec_server_protocol::ExecutorCapabilityDiscoverySnapshot; use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TurnEnvironmentSelection; use serde_json::Value; @@ -47,6 +48,7 @@ pub struct RenderedWorldStateFragment { role: &'static str, markers: (&'static str, &'static str), body: String, + metadata: ContentItemMetadata, } impl RenderedWorldStateFragment { @@ -59,9 +61,20 @@ impl RenderedWorldStateFragment { role, markers, body: body.into(), + metadata: ContentItemMetadata::harness(), } } + /// Attach the producer's source before the harness assembles the context message. + pub fn with_metadata(mut self, metadata: ContentItemMetadata) -> Self { + self.metadata = metadata; + self + } + + pub fn metadata(&self) -> &ContentItemMetadata { + &self.metadata + } + pub fn role(&self) -> &'static str { self.role } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs index 3f4cc73fb8..924312ff99 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs @@ -17,7 +17,7 @@ fn rendered_classifier_text(config: &GuardianV2Config, policy: &str) -> String { let (_, content) = config .render_classifier_instructions(policy, "") .into_parts(); - let (ContentItem::InputText { text }, _) = content.into_parts() else { + let (ContentItem::InputText { text }, _, _) = content.into_parts() else { panic!("classifier instructions must be text"); }; text @@ -53,7 +53,7 @@ fn extra_policy_reaches_templated_and_legacy_classifier_instructions() { let (_, content) = config .render_classifier_instructions(policy, extra_policy) .into_parts(); - let (ContentItem::InputText { text }, _) = content.into_parts() else { + let (ContentItem::InputText { text }, _, _) = content.into_parts() else { panic!("classifier instructions must be text"); }; assert_eq!( diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs index 30968d6b6c..9618b51dc2 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/decisions_tests.rs @@ -4,6 +4,7 @@ use codex_context_fragments::ContextualUserFragment; use codex_guardian_context::TrustedTool; use codex_http_client::HttpClientBuilder; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use pretty_assertions::assert_eq; use wiremock::Mock; use wiremock::MockServer; @@ -17,7 +18,11 @@ use wiremock::matchers::path; fn adapter_rejects_unsupported_evidence() { let rubric = RenderedFragment::new( "developer", - AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())), + AnnotatedContent::text( + "Classify risk.", + ContentItemKind("guardian.test".into()), + ContentItemMetadata::harness(), + ), ); let user: ResponseItem = serde_json::from_value(json!({"type":"message", "role":"user", "content":[ {"type":"input_text", "text":"Authorized task"}, {"type":"input_text", "text":"Planned action"} @@ -53,7 +58,11 @@ fn adapter_rejects_unsupported_evidence() { fn adapter_preserves_trusted_tool_authority_and_scope() { let rubric = RenderedFragment::new( "developer", - AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())), + AnnotatedContent::text( + "Classify risk.", + ContentItemKind("guardian.test".into()), + ContentItemMetadata::harness(), + ), ); let tool = TrustedTool { server: "example".into(), @@ -123,7 +132,11 @@ async fn http_contract_and_untrusted_response_validation() { let mut request = super::super::sampler::tests::sample_request("turn"); request.instructions = RenderedFragment::new( "developer", - AnnotatedContent::input_text("Classify risk", ContentItemKind("guardian.test".into())), + AnnotatedContent::text( + "Classify risk", + ContentItemKind("guardian.test".into()), + ContentItemMetadata::harness(), + ), ); let body = json!({"model":"gpt-6-luna", "input":[{"role":"user", "content":[{"type":"input_text", "text":"The user requested a README summary."}]}], diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 707e0d5f68..b148f557be 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -1379,6 +1379,9 @@ classifier_instructions = "Predict future violations.\n# Security Policy\n{{ ten "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -1421,6 +1424,9 @@ max_classifier_instruction_tokens = 256 "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -1505,6 +1511,9 @@ max_recent_non_user_entries = 8 "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -1937,6 +1946,9 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -2118,6 +2130,9 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -2813,6 +2828,9 @@ async fn contributor_uses_catalog_policy_without_a_configured_override() -> Resu "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", @@ -2859,6 +2877,9 @@ async fn contributor_preserves_uncapped_classifier_instructions() -> Result<()> "role": "developer", "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, "content": [{ "type": "input_text", diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs index 53397c38db..4f1afc5cf8 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs @@ -258,6 +258,9 @@ fn assert_classifier_instructions(request: &serde_json::Value) { }], "internal_chat_message_metadata_passthrough": { "content_item_kinds": ["guardian.classifier_instructions"], + "content_item_metadata": [{ + "provenance": {"type": "harness", "hook": false}, + }], }, }) ); diff --git a/codex-rs/ext/history-notes/src/extension.rs b/codex-rs/ext/history-notes/src/extension.rs index a5b259f1a9..ff425ec8fa 100644 --- a/codex-rs/ext/history-notes/src/extension.rs +++ b/codex-rs/ext/history-notes/src/extension.rs @@ -145,10 +145,11 @@ impl ContextContributor for HistoryNotesExtension { if text.is_empty() { return Vec::new(); } - vec![PromptFragment::new( + vec![PromptFragment::tool( PromptSlot::ContextWindow, text, ContentItemKind("notes.thread_hint".to_string()), + "notes".to_string(), )] }) } diff --git a/codex-rs/ext/history-notes/tests/history_notes_extension.rs b/codex-rs/ext/history-notes/tests/history_notes_extension.rs index 219155a3ca..02e7a4d41c 100644 --- a/codex-rs/ext/history-notes/tests/history_notes_extension.rs +++ b/codex-rs/ext/history-notes/tests/history_notes_extension.rs @@ -165,10 +165,11 @@ async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestRe .await; assert_eq!( hints, - vec![PromptFragment::new( + vec![PromptFragment::tool( PromptSlot::ContextWindow, THREAD_HINT, ContentItemKind("notes.thread_hint".to_string()), + "notes".to_string(), )] ); diff --git a/codex-rs/ext/skills/src/fragments.rs b/codex-rs/ext/skills/src/fragments.rs index 0e2e3018db..7ee33f5e5e 100644 --- a/codex-rs/ext/skills/src/fragments.rs +++ b/codex-rs/ext/skills/src/fragments.rs @@ -1,5 +1,6 @@ use codex_extension_api::ContextualUserFragment; use codex_protocol::models::ContentItemKind; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -37,6 +38,10 @@ impl AvailableSkillsInstructions { } impl ContextualUserFragment for AvailableSkillsInstructions { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::skills() + } + fn role(&self) -> &'static str { "developer" } @@ -74,6 +79,10 @@ pub(crate) struct SkillResourceAccess { } impl ContextualUserFragment for SkillInstructions { + fn content_metadata(&self) -> ContentItemMetadata { + ContentItemMetadata::skills() + } + fn role(&self) -> &'static str { "user" } diff --git a/codex-rs/ext/skills/src/world_state.rs b/codex-rs/ext/skills/src/world_state.rs index d4ad2dc041..14a855d616 100644 --- a/codex-rs/ext/skills/src/world_state.rs +++ b/codex-rs/ext/skills/src/world_state.rs @@ -1,6 +1,7 @@ use codex_extension_api::PreviousWorldStateSection; use codex_extension_api::RenderedWorldStateFragment; use codex_extension_api::WorldStateSectionContribution; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use serde::Serialize; @@ -82,7 +83,7 @@ pub(crate) fn executor_skills_world_state_section( "developer", (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG), "\n## Skills update\nThe previously listed selected-environment skills are available again.\n", - )), + ).with_metadata(ContentItemMetadata::skills())), ); } } @@ -102,7 +103,7 @@ pub(crate) fn executor_skills_world_state_section( "developer", (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG), body, - )), + ).with_metadata(ContentItemMetadata::skills())), ) }) .with_legacy_matcher(|role, text| { @@ -187,11 +188,14 @@ fn skills_world_state_section( ( Some(snapshot.clone()), - Some(RenderedWorldStateFragment::new( - "developer", - (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG), - body, - )), + Some( + RenderedWorldStateFragment::new( + "developer", + (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG), + body, + ) + .with_metadata(ContentItemMetadata::skills()), + ), ) }); match retained_body { diff --git a/codex-rs/hooks/src/context.rs b/codex-rs/hooks/src/context.rs new file mode 100644 index 0000000000..d4afe5afe5 --- /dev/null +++ b/codex-rs/hooks/src/context.rs @@ -0,0 +1,18 @@ +//! Model-visible hook context, attributed by the executor that produced it. + +use codex_protocol::models::ContentItemMetadata; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookContext { + pub text: String, + pub metadata: ContentItemMetadata, +} + +impl HookContext { + pub fn harness(text: String) -> Self { + Self { + text, + metadata: ContentItemMetadata::harness(), + } + } +} diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index 674f5bd2ed..d323c2131e 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -43,7 +43,6 @@ use super::dispatcher::hook_handler_type_label; use super::dispatcher::hook_scope_label; use super::dispatcher::hook_source_label; use super::dispatcher::scope_for_event; -use crate::output_spill::AdditionalContext; use crate::output_spill::HookOutputSpiller; use codex_protocol::ThreadId; use codex_protocol::protocol::HookCompletedEvent; @@ -141,21 +140,17 @@ impl CommandHookRuntime { for entry in std::mem::take(&mut hook_result.run.entries) { match entry.kind { HookOutputEntryKind::Context => { - if let Some(text) = runtime + let text = runtime .output_spiller - .maybe_spill_additional_contexts(vec![AdditionalContext { - text: entry.text, - limit: handler.additional_context_limit, - }]) - .await - .into_iter() - .next() - { - entries.push(HookOutputEntry { - kind: HookOutputEntryKind::Context, - text, - }); - } + .maybe_spill_text_with_limit( + entry.text, + handler.additional_context_limit, + ) + .await; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Context, + text, + }); } HookOutputEntryKind::Warning => warnings.push(entry), HookOutputEntryKind::Error => entries.push(entry), @@ -371,6 +366,7 @@ fn finish_command_run( ) -> HandlerRunResult { Span::current().record("hook.command_outcome", completion.outcome); HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::command_hook(), started_at, completed_at: chrono::Utc::now().timestamp(), duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index b34118d6f2..30c0c34961 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -146,7 +146,8 @@ pub(crate) async fn execute_handlers_with_metadata( pending.push(async move { let result = execute_handler(engine, &handler, &input_json, cwd, /*metadata*/ None).await; - (configured_order, parse(&handler, result, turn_id)) + let parsed = parse(&handler, result, turn_id); + (configured_order, parsed) }); } diff --git a/codex-rs/hooks/src/engine/mcp_runner.rs b/codex-rs/hooks/src/engine/mcp_runner.rs index 71bf050e0f..18cb81e185 100644 --- a/codex-rs/hooks/src/engine/mcp_runner.rs +++ b/codex-rs/hooks/src/engine/mcp_runner.rs @@ -4,6 +4,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; +use codex_protocol::models::ContentItemMetadata; use regex::Regex; use serde_json::Map; use serde_json::Value; @@ -75,11 +76,12 @@ pub(crate) async fn run_mcp_tool( } .await; - let (exit_code, stdout, error) = match result { - Ok(output) => (Some(0), output, None), - Err(error) => (None, String::new(), Some(error.to_string())), + let (exit_code, stdout, error, source_tool_namespace) = match result { + Ok(output) => (Some(0), output.text, None, output.source_tool_namespace), + Err(error) => (None, String::new(), Some(error.to_string()), None), }; HandlerRunResult { + context_metadata: ContentItemMetadata::tool(source_tool_namespace.map(Into::into)), started_at, completed_at: chrono::Utc::now().timestamp(), duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), diff --git a/codex-rs/hooks/src/engine/mcp_runner_tests.rs b/codex-rs/hooks/src/engine/mcp_runner_tests.rs index a16613ea07..4c4726d284 100644 --- a/codex-rs/hooks/src/engine/mcp_runner_tests.rs +++ b/codex-rs/hooks/src/engine/mcp_runner_tests.rs @@ -15,6 +15,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::ConfiguredHandlerKind; use crate::mcp::HookMcpCall; use crate::mcp::HookMcpExecutor; +use crate::mcp::HookMcpOutput; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::test_support::PathBufExt; @@ -26,10 +27,13 @@ struct RecordingExecutor { } impl HookMcpExecutor for RecordingExecutor { - fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { + fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { async move { self.calls.lock().expect("lock calls").push(call); - Ok(self.output.clone()) + Ok(HookMcpOutput { + text: self.output.clone(), + source_tool_namespace: Some("security".into()), + }) } .boxed() } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index a007f3b1a4..f1cd5cf808 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -131,6 +131,7 @@ pub(crate) enum ConfiguredHandlerKind { #[derive(Debug)] pub(crate) struct HandlerRunResult { + pub context_metadata: codex_protocol::models::ContentItemMetadata, pub started_at: i64, pub completed_at: i64, pub duration_ms: i64, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a185e45029..43167e78a1 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -51,6 +51,7 @@ use crate::events::stop::StopHookTarget; use crate::events::stop::StopRequest; use crate::mcp::HookMcpCall; use crate::mcp::HookMcpExecutor; +use crate::mcp::HookMcpOutput; fn cwd() -> AbsolutePathBuf { AbsolutePathBuf::current_dir().expect("current dir") @@ -2237,7 +2238,7 @@ struct StaticMcpExecutor { } impl HookMcpExecutor for StaticMcpExecutor { - fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { + fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { async move { let output = self .outputs_by_tool @@ -2245,7 +2246,10 @@ impl HookMcpExecutor for StaticMcpExecutor { .unwrap_or(&self.output) .clone(); self.calls.lock().expect("lock MCP calls").push(call); - Ok(output) + Ok(HookMcpOutput { + text: output, + source_tool_namespace: None, + }) } .boxed() } @@ -2650,11 +2654,14 @@ async fn executor_stop_hooks_do_not_delay_stop_completion() { } impl HookMcpExecutor for BlockingMcpExecutor { - fn execute(&self, _call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { + fn execute(&self, _call: HookMcpCall) -> BoxFuture<'_, anyhow::Result> { async move { self.started.notify_one(); self.release.notified().await; - Ok(String::new()) + Ok(HookMcpOutput { + text: String::new(), + source_tool_namespace: None, + }) } .boxed() } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index c6e1a1d324..2a58d51634 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -9,6 +9,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::HandlerSourcePath; use crate::engine::dispatcher; use crate::output_spill::AdditionalContext; +use codex_protocol::models::ContentItemMetadata; /// Identifies a thread-spawned subagent when a normal hook runs inside it. #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,13 +40,17 @@ pub(crate) fn append_additional_context( additional_contexts_for_model: &mut Vec, handler: &ConfiguredHandler, additional_context: String, + metadata: &ContentItemMetadata, ) { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Context, text: additional_context.clone(), }); additional_contexts_for_model.push(AdditionalContext { - text: additional_context, + context: crate::HookContext { + text: additional_context, + metadata: metadata.clone(), + }, limit: handler.additional_context_limit, }); } @@ -53,10 +58,7 @@ pub(crate) fn append_additional_context( pub(crate) fn flatten_additional_contexts<'a>( additional_contexts: impl IntoIterator, ) -> Vec { - additional_contexts - .into_iter() - .flat_map(|chunk| chunk.iter().cloned()) - .collect() + additional_contexts.into_iter().flatten().cloned().collect() } pub(crate) fn serialization_failure_hook_events( diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 82bd6c6cf5..2509baae46 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -542,6 +542,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1_700_000_000, completed_at: 1_700_000_001, duration_ms: 12, diff --git a/codex-rs/hooks/src/events/interrupt_tests.rs b/codex-rs/hooks/src/events/interrupt_tests.rs index 3d8642c845..7ce3822126 100644 --- a/codex-rs/hooks/src/events/interrupt_tests.rs +++ b/codex-rs/hooks/src/events/interrupt_tests.rs @@ -117,6 +117,7 @@ fn handler() -> ConfiguredHandler { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 0ca0f6c673..1c62d364d1 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -40,7 +40,7 @@ pub struct PostToolUseRequest { pub struct PostToolUseOutcome { pub hook_events: Vec, pub should_block: bool, - pub additional_contexts: Vec, + pub additional_contexts: Vec, pub feedback_message: Option, } @@ -206,6 +206,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } if handler.can_apply_control_effects() { @@ -391,7 +392,9 @@ mod tests { PostToolUseHandlerData { should_block: false, additional_contexts_for_model: vec![AdditionalContext { - text: "Remember the bash cleanup note.".to_string(), + context: crate::HookContext::harness( + "Remember the bash cleanup note.".to_string() + ), limit: AdditionalContextLimit::from_config(Some(17)), }], feedback_messages_for_model: Vec::new(), @@ -608,6 +611,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1_700_000_000, completed_at: 1_700_000_001, duration_ms: 12, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index e57b823a1c..a47b1ff2b0 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -40,7 +40,7 @@ pub struct PreToolUseOutcome { pub hook_events: Vec, pub should_block: bool, pub block_reason: Option, - pub additional_contexts: Vec, + pub additional_contexts: Vec, pub updated_input: Option, } @@ -229,6 +229,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } if handler.can_apply_control_effects() { @@ -530,7 +531,7 @@ mod tests { should_block: true, block_reason: Some("do not run that".to_string()), additional_contexts_for_model: vec![AdditionalContext { - text: "remember this".to_string(), + context: crate::HookContext::harness("remember this".to_string()), limit: Default::default(), }], updated_input: None, @@ -630,7 +631,7 @@ mod tests { should_block: true, block_reason: Some("do not run that".to_string()), additional_contexts_for_model: vec![AdditionalContext { - text: "nope".to_string(), + context: crate::HookContext::harness("nope".to_string()), limit: AdditionalContextLimit::from_config(Some(13)), }], updated_input: None, @@ -792,6 +793,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/events/session_end_tests.rs b/codex-rs/hooks/src/events/session_end_tests.rs index 05fa107d4f..2a1ada4a78 100644 --- a/codex-rs/hooks/src/events/session_end_tests.rs +++ b/codex-rs/hooks/src/events/session_end_tests.rs @@ -43,6 +43,7 @@ fn session_end_ignores_successful_output() { let completed = parse_completed( &handler(/*matcher*/ None), HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 3aff0fe946..79ea63f335 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -84,7 +84,7 @@ pub struct SessionStartOutcome { pub hook_events: Vec, pub should_stop: bool, pub stop_reason: Option, - pub additional_contexts: Vec, + pub additional_contexts: Vec, } #[derive(Debug, PartialEq, Eq)] @@ -267,6 +267,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } let _ = parsed.universal.suppress_output; @@ -308,6 +309,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } } @@ -386,7 +388,7 @@ mod tests { should_stop: false, stop_reason: None, additional_contexts_for_model: vec![AdditionalContext { - text: "hello from hook".to_string(), + context: crate::HookContext::harness("hello from hook".to_string()), limit: AdditionalContextLimit::from_config(Some(7)), }], } @@ -419,7 +421,7 @@ mod tests { should_stop: true, stop_reason: Some("pause".to_string()), additional_contexts_for_model: vec![AdditionalContext { - text: "do not inject".to_string(), + context: crate::HookContext::harness("do not inject".to_string()), limit: Default::default(), }], } @@ -486,7 +488,7 @@ mod tests { should_stop: false, stop_reason: None, additional_contexts_for_model: vec![AdditionalContext { - text: "hello from subagent hook".to_string(), + context: crate::HookContext::harness("hello from subagent hook".to_string()), limit: AdditionalContextLimit::from_config(Some(4_096)), }], } @@ -520,7 +522,7 @@ mod tests { should_stop: false, stop_reason: None, additional_contexts_for_model: vec![AdditionalContext { - text: "child context".to_string(), + context: crate::HookContext::harness("child context".to_string()), limit: Default::default(), }], } @@ -561,6 +563,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index be2be9100e..5e2b300153 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -390,10 +390,11 @@ fn parse_completed( }; let continuation_fragments = continuation_prompt .map(|prompt| { - vec![HookPromptFragment::from_single_hook( - prompt, - completed.run.id.clone(), - )] + vec![HookPromptFragment { + text: prompt, + hook_run_id: completed.run.id.clone(), + metadata: run_result.context_metadata, + }] }) .unwrap_or_default(); @@ -497,6 +498,7 @@ mod tests { continuation_fragments: vec![HookPromptFragment { text: "retry with tests".to_string(), hook_run_id: parsed.completed.run.id.clone(), + metadata: codex_protocol::models::ContentItemMetadata::harness(), }], } ); @@ -574,6 +576,7 @@ mod tests { continuation_fragments: vec![HookPromptFragment { text: "retry with tests".to_string(), hook_run_id: parsed.completed.run.id.clone(), + metadata: codex_protocol::models::ContentItemMetadata::harness(), }], } ); @@ -711,6 +714,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 9124431bc1..bc856b251a 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -37,7 +37,7 @@ pub struct UserPromptSubmitOutcome { pub hook_events: Vec, pub should_stop: bool, pub stop_reason: Option, - pub additional_contexts: Vec, + pub additional_contexts: Vec, } #[derive(Debug, PartialEq, Eq)] @@ -176,6 +176,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } let _ = parsed.universal.suppress_output; @@ -221,6 +222,7 @@ fn parse_completed( &mut additional_contexts_for_model, handler, additional_context, + &run_result.context_metadata, ); } } @@ -317,7 +319,7 @@ mod tests { should_stop: true, stop_reason: Some("pause".to_string()), additional_contexts_for_model: vec![AdditionalContext { - text: "do not inject".to_string(), + context: crate::HookContext::harness("do not inject".to_string()), limit: Default::default(), }], } @@ -356,7 +358,7 @@ mod tests { should_stop: true, stop_reason: Some("slow down".to_string()), additional_contexts_for_model: vec![AdditionalContext { - text: "do not inject".to_string(), + context: crate::HookContext::harness("do not inject".to_string()), limit: Default::default(), }], } @@ -480,6 +482,7 @@ mod tests { fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> HandlerRunResult { HandlerRunResult { + context_metadata: codex_protocol::models::ContentItemMetadata::harness(), started_at: 1, completed_at: 2, duration_ms: 1, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 6abc173535..9be500af78 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,4 +1,5 @@ mod config_rules; +mod context; mod declarations; mod engine; pub(crate) mod events; @@ -52,6 +53,7 @@ pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 9] = [ "SubagentStop", ]; +pub use context::HookContext; pub use events::compact::PostCompactRequest; pub use events::compact::PreCompactOutcome; pub use events::compact::PreCompactRequest; @@ -79,6 +81,7 @@ pub use events::user_prompt_submit::UserPromptSubmitRequest; pub use legacy_notify::legacy_notify_json; pub use mcp::HookMcpCall; pub use mcp::HookMcpExecutor; +pub use mcp::HookMcpOutput; pub use registry::HookListOutcome; pub use registry::Hooks; pub use registry::HooksConfig; diff --git a/codex-rs/hooks/src/mcp.rs b/codex-rs/hooks/src/mcp.rs index 49d907df80..ca95d7b0cb 100644 --- a/codex-rs/hooks/src/mcp.rs +++ b/codex-rs/hooks/src/mcp.rs @@ -15,10 +15,16 @@ pub struct HookMcpCall { pub timeout: Duration, } +/// Text returned by an MCP hook and the callable namespace that supplied it. +pub struct HookMcpOutput { + pub text: String, + pub source_tool_namespace: Option, +} + /// Executes already-connected MCP tools on behalf of hooks without coupling this crate to core. /// /// Implementations own server readiness, policy enforcement, timeout handling, and elicitation. pub trait HookMcpExecutor: Send + Sync { /// Returns text that is interpreted using ordinary command-hook output semantics. - fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result>; + fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result>; } diff --git a/codex-rs/hooks/src/output_spill.rs b/codex-rs/hooks/src/output_spill.rs index b60ac97399..a11eebf078 100644 --- a/codex-rs/hooks/src/output_spill.rs +++ b/codex-rs/hooks/src/output_spill.rs @@ -1,3 +1,4 @@ +use crate::HookContext; use codex_protocol::ThreadId; use codex_protocol::items::HookPromptFragment; use codex_utils_absolute_path::AbsolutePathBuf; @@ -32,7 +33,7 @@ impl Default for AdditionalContextLimit { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AdditionalContext { - pub text: String, + pub context: HookContext, pub limit: AdditionalContextLimit, } @@ -61,7 +62,7 @@ impl HookOutputSpiller { .await } - async fn maybe_spill_text_with_limit( + pub(crate) async fn maybe_spill_text_with_limit( &self, text: String, limit: AdditionalContextLimit, @@ -93,13 +94,11 @@ impl HookOutputSpiller { pub(crate) async fn maybe_spill_additional_contexts( &self, contexts: Vec, - ) -> Vec { + ) -> Vec { let mut spilled = Vec::with_capacity(contexts.len()); - for context in contexts { - spilled.push( - self.maybe_spill_text_with_limit(context.text, context.limit) - .await, - ); + for AdditionalContext { mut context, limit } in contexts { + context.text = self.maybe_spill_text_with_limit(context.text, limit).await; + spilled.push(context); } spilled } @@ -113,6 +112,7 @@ impl HookOutputSpiller { spilled.push(HookPromptFragment { text: self.maybe_spill_text(fragment.text).await, hook_run_id: fragment.hook_run_id, + metadata: fragment.metadata, }); } spilled diff --git a/codex-rs/hooks/src/output_spill_tests.rs b/codex-rs/hooks/src/output_spill_tests.rs index 6b72c956ce..6bbd90e33c 100644 --- a/codex-rs/hooks/src/output_spill_tests.rs +++ b/codex-rs/hooks/src/output_spill_tests.rs @@ -60,15 +60,15 @@ async fn additional_contexts_apply_limits_individually() -> Result<()> { let output = spiller .maybe_spill_additional_contexts(vec![ AdditionalContext { - text: limited_text.clone(), + context: crate::HookContext::harness(limited_text.clone()), limit: AdditionalContextLimit::from_config(Some(1)), }, AdditionalContext { - text: unlimited_text.clone(), + context: crate::HookContext::harness(unlimited_text.clone()), limit: AdditionalContextLimit::from_config(Some(0)), }, AdditionalContext { - text: unlimited_text.clone(), + context: crate::HookContext::harness(unlimited_text.clone()), limit: AdditionalContextLimit::from_config(Some(usize::MAX)), }, ]) @@ -76,8 +76,8 @@ async fn additional_contexts_apply_limits_individually() -> Result<()> { let [limited_output, zero_limit_output, high_limit_output] = output.as_slice() else { panic!("expected one output for each additional context"); }; - assert!(limited_output.contains("Full hook output saved to:")); - assert_eq!(zero_limit_output, &unlimited_text); - assert_eq!(high_limit_output, &unlimited_text); + assert!(limited_output.text.contains("Full hook output saved to:")); + assert_eq!(&zero_limit_output.text, &unlimited_text); + assert_eq!(&high_limit_output.text, &unlimited_text); Ok(()) } diff --git a/codex-rs/prompts/src/multi_agent_instructions_tests.rs b/codex-rs/prompts/src/multi_agent_instructions_tests.rs index 82fbb871ad..ba3078823c 100644 --- a/codex-rs/prompts/src/multi_agent_instructions_tests.rs +++ b/codex-rs/prompts/src/multi_agent_instructions_tests.rs @@ -3,6 +3,7 @@ use super::*; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::RenderedFragment; +use codex_protocol::models::ContentItemMetadata; use pretty_assertions::assert_eq; #[test] @@ -32,9 +33,10 @@ fn role_segment_filters_base_and_appends_bundled_guidance() { instructions.render_fragment(), RenderedFragment::new( "developer", - AnnotatedContent::input_text( + AnnotatedContent::text( expected_text, ContentItemKind("multi_agent.role_instructions".to_string()), + ContentItemMetadata::harness(), ), ), ); diff --git a/codex-rs/prompts/src/permissions_instructions_tests.rs b/codex-rs/prompts/src/permissions_instructions_tests.rs index 586fcc134a..51a8492f4a 100644 --- a/codex-rs/prompts/src/permissions_instructions_tests.rs +++ b/codex-rs/prompts/src/permissions_instructions_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::ResolvedModelMessages; use codex_context_fragments::AnnotatedContent; use codex_context_fragments::RenderedFragment; +use codex_protocol::models::ContentItemMetadata; use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::PermissionMessages; use codex_protocol::permissions::FileSystemAccessMode; @@ -738,9 +739,10 @@ fn preserves_supplied_path_spellings_and_order() { instructions.render_fragment(), RenderedFragment::new( "developer", - AnnotatedContent::input_text( + AnnotatedContent::text( format!("{expected_body}"), ContentItemKind("permissions.instructions".to_string()), + ContentItemMetadata::harness(), ), ), ); diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 8cf7e4646d..d44ffbc868 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -5,9 +5,11 @@ use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::mcp::CallToolResult; use crate::memory_citation::MemoryCitation; use crate::models::ContentItem; +use crate::models::ContentItemMetadata; use crate::models::FunctionCallOutputBody; use crate::models::ImageDetail; use crate::models::ImageReference; +use crate::models::InternalChatMessageMetadataPassthrough; use crate::models::MessagePhase; use crate::models::ResponseItem; use crate::models::WebSearchAction; @@ -108,6 +110,11 @@ pub struct HookPromptItem { pub struct HookPromptFragment { pub text: String, pub hook_run_id: String, + /// Emission-time attribution; persisted on the ResponseItem, not the UI hook fragment. + #[serde(skip)] + #[ts(skip)] + #[schemars(skip)] + pub metadata: ContentItemMetadata, } #[derive(Debug, Deserialize, Serialize)] @@ -668,19 +675,20 @@ impl HookPromptFragment { Self { text: text.into(), hook_run_id: hook_run_id.into(), + metadata: ContentItemMetadata::default(), } } } pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option { - let content = fragments - .iter() - .filter(|fragment| !fragment.hook_run_id.trim().is_empty()) - .filter_map(|fragment| { - serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id) - .map(|text| ContentItem::InputText { text }) - }) - .collect::>(); + let mut content = Vec::new(); + let mut metadata = Vec::new(); + for fragment in fragments { + if let Some(text) = serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id) { + content.push(ContentItem::InputText { text }); + metadata.push(fragment.metadata.clone()); + } + } if content.is_empty() { return None; @@ -691,7 +699,13 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option Option { return None; } - Some(HookPromptFragment { text, hook_run_id }) + Some(HookPromptFragment::from_single_hook(text, hook_run_id)) } fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option { @@ -863,6 +877,7 @@ mod tests { HookPromptFragment { text: "Retry with tests.".to_string(), hook_run_id: "hook-run-1".to_string(), + metadata: Default::default(), } ); } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f0ac0e51d7..58e20012d1 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -52,6 +52,9 @@ pub use executed_tool_calls::bound_executed_tool_calls_for_message; pub use executed_tool_calls::executed_tool_call_metadata_bytes; pub use executed_tool_calls::normalize_executed_tool_call_arguments; pub use item_metadata::ContentItemKind; +pub use item_metadata::ContentItemMetadata; +pub use item_metadata::ContentItemNamespace; +pub use item_metadata::ContentItemProvenance; /// Controls the per-command sandbox override requested by a shell-like tool call. #[derive( @@ -974,6 +977,12 @@ pub struct InternalChatMessageMetadataPassthrough { #[schemars(skip)] #[ts(skip)] pub content_item_kinds: Option>, + /// Positional source attribution for `content`; missing historical entries are Unknown. + #[serde_as(deserialize_as = "serde_with::DefaultOnError")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(skip)] + #[ts(skip)] + pub content_item_metadata: Option>, // Ignore input values so requests and rollout reloads cannot fake tool call records. // A resumed thread can record new calls, but cannot prove old calls from this payload. /// Host-owned Code Mode cell shared by its `exec` and subsequent `wait` outputs. @@ -1409,7 +1418,7 @@ impl ResponseItem { } } - /// Removes content item classifications while preserving other passthrough metadata. + /// Removes content item classifications and attribution, preserving other metadata. pub fn clear_content_item_kinds(&mut self) { let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; @@ -1419,11 +1428,24 @@ impl ResponseItem { }; metadata_value.content_item_kinds = None; + metadata_value.content_item_metadata = None; if metadata_value == &InternalChatMessageMetadataPassthrough::default() { *metadata = None; } } + /// Removes per-content attribution for endpoints that do not yet accept it. + pub fn clear_content_item_metadata(&mut self) { + if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() + && let Some(value) = metadata + { + value.content_item_metadata = None; + if value == &InternalChatMessageMetadataPassthrough::default() { + *metadata = None; + } + } + } + fn internal_chat_message_metadata_passthrough( &self, ) -> Option<&InternalChatMessageMetadataPassthrough> { diff --git a/codex-rs/protocol/src/models/executed_tool_calls.rs b/codex-rs/protocol/src/models/executed_tool_calls.rs index 17429ff684..2a817a6a0e 100644 --- a/codex-rs/protocol/src/models/executed_tool_calls.rs +++ b/codex-rs/protocol/src/models/executed_tool_calls.rs @@ -38,6 +38,7 @@ fn executed_tool_call_metadata_field_bytes( } else if metadata.turn_id.is_some() || metadata.create_time.is_some() || metadata.content_item_kinds.is_some() + || metadata.content_item_metadata.is_some() { bytes + 1 } else { diff --git a/codex-rs/protocol/src/models/item_metadata.rs b/codex-rs/protocol/src/models/item_metadata.rs index af08dfa827..c3d1326697 100644 --- a/codex-rs/protocol/src/models/item_metadata.rs +++ b/codex-rs/protocol/src/models/item_metadata.rs @@ -1,3 +1,5 @@ +//! Per-part context sources, independent of message role, visibility, and trust. + use serde::Deserialize; use serde::Serialize; @@ -12,6 +14,151 @@ impl ContentItemKind { } } +/// A callable namespace. Built-ins are typed; configured and MCP namespaces stay open. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(from = "String", into = "String")] +pub enum ContentItemNamespace { + Clock, + Functions, + ToolSearch, + Collaboration, + MultiAgentV1, + Other(String), +} + +impl From for ContentItemNamespace { + fn from(namespace: String) -> Self { + match namespace.as_str() { + "clock" => Self::Clock, + "functions" => Self::Functions, + "tool_search" => Self::ToolSearch, + "collaboration" => Self::Collaboration, + "multi_agent_v1" => Self::MultiAgentV1, + _ => Self::Other(namespace), + } + } +} + +impl From for String { + fn from(namespace: ContentItemNamespace) -> Self { + match namespace { + ContentItemNamespace::Clock => "clock".to_string(), + ContentItemNamespace::Functions => "functions".to_string(), + ContentItemNamespace::ToolSearch => "tool_search".to_string(), + ContentItemNamespace::Collaboration => "collaboration".to_string(), + ContentItemNamespace::MultiAgentV1 => "multi_agent_v1".to_string(), + ContentItemNamespace::Other(namespace) => namespace, + } + } +} + +/// Who supplied a part. Tool-related context remains Tool even when the harness writes it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentItemProvenance { + /// History saved before attribution, or content whose source is not known. + #[default] + #[serde(alias = "user_configured")] + Unknown, + /// The regular developer-instruction slot or additional requirements-layer instructions. + DeveloperInstructions { from_additional_requirements: bool }, + /// Skill catalogs, usage guidance, and selected skill contents. + Skills, + /// Instructions loaded from AGENTS.md. + AgentsMd, + /// Harness-managed context; command-hook output is distinguished from built-in context. + Harness { + #[serde(default)] + hook: bool, + }, + /// A tool result or tool-specific context; an unlisted tool can have no known namespace. + Tool { + #[serde(default, skip_serializing_if = "Option::is_none")] + namespace: Option, + }, + /// Ordinary user input, not an injected instruction fragment with a user role. + User, +} + +/// Attribution for one position in a harness-emitted item's content array. +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct ContentItemMetadata { + pub provenance: ContentItemProvenance, +} + +impl<'de> Deserialize<'de> for ContentItemMetadata { + fn deserialize>(deserializer: D) -> Result { + // Older records may lack attribution. Neither the old user_configured category + // nor a bare harness_injected:false identifies a source precisely enough. + #[derive(Deserialize)] + struct StoredMetadata { + #[serde(default)] + provenance: Option, + #[serde(default)] + harness_injected: Option, + #[serde(default)] + source_tool_namespace: Option, + } + let stored = StoredMetadata::deserialize(deserializer)?; + let provenance = stored.provenance.unwrap_or_else(|| { + match (stored.source_tool_namespace, stored.harness_injected) { + (Some(namespace), _) => ContentItemProvenance::Tool { + namespace: Some(namespace.into()), + }, + (None, Some(true)) => ContentItemProvenance::Harness { hook: false }, + (None, Some(false) | None) => ContentItemProvenance::Unknown, + } + }); + Ok(Self { provenance }) + } +} + +impl ContentItemMetadata { + pub fn harness() -> Self { + Self { + provenance: ContentItemProvenance::Harness { hook: false }, + } + } + + pub fn command_hook() -> Self { + Self { + provenance: ContentItemProvenance::Harness { hook: true }, + } + } + + pub fn developer_instructions(from_additional_requirements: bool) -> Self { + Self { + provenance: ContentItemProvenance::DeveloperInstructions { + from_additional_requirements, + }, + } + } + + pub fn skills() -> Self { + Self { + provenance: ContentItemProvenance::Skills, + } + } + + pub fn agents_md() -> Self { + Self { + provenance: ContentItemProvenance::AgentsMd, + } + } + + pub fn user() -> Self { + Self { + provenance: ContentItemProvenance::User, + } + } + + pub fn tool(namespace: Option) -> Self { + Self { + provenance: ContentItemProvenance::Tool { namespace }, + } + } +} + #[cfg(test)] #[path = "item_metadata_tests.rs"] mod tests;