Track source attribution for individual context parts (#52195)

## What changed

- Add `ContentItemMetadata` to distinguish user input, developer instructions, `AGENTS.md`, skills, harness context, and tool-supplied context independently of message role and content classification.
- Keep attribution attached to content through prompt assembly, history processing, and hook-output spilling. Distinguish command hooks from MCP hooks and retain tool namespaces when known.
- Treat missing historical attribution as unknown and send per-content metadata only to Responses Lite.

## Testing

Update request and history assertions to cover attribution persistence and Responses Lite metadata handling.

GitOrigin-RevId: 24e56005f5bdb98bf82f2b3943f29c4c81f3d587
This commit is contained in:
tongzhou wang
2026-10-08 17:56:54 +00:00
committed by copyberry
parent f90a578f76
commit ad54b75574
78 changed files with 943 additions and 260 deletions
@@ -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("<context_window>"))
+1
View File
@@ -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;
+41 -13
View File
@@ -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<String>,
}
/// 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<ReadResourceResult> {
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<serde_json::Value>,
requested_timeout: Option<Duration>,
wait_for_server: bool,
) -> anyhow::Result<CallToolResult> {
self.latest_connections()
) -> anyhow::Result<McpToolCallResult> {
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<ReadResourceResult> {
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 {
@@ -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<String>, 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<String>,
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<String>, parts: Vec<AnnotatedContent>) -> 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<Vec<AnnotatedContent>> {
let ResponseItem::Message {
content,
@@ -59,6 +86,10 @@ pub fn to_annotated_content(item: &mut ResponseItem) -> Option<Vec<AnnotatedCont
.as_mut()
.and_then(|metadata| metadata.content_item_kinds.take())
.unwrap_or_default();
let metadata = internal_chat_message_metadata_passthrough
.as_mut()
.and_then(|metadata| metadata.content_item_metadata.take())
.unwrap_or_default();
Some(
std::mem::take(content)
@@ -66,12 +97,21 @@ pub fn to_annotated_content(item: &mut ResponseItem) -> Option<Vec<AnnotatedCont
.zip(kinds.into_iter().chain(std::iter::repeat_with(|| {
ContentItemKind("unknown".to_string())
})))
.map(|(content, kind)| AnnotatedContent::new(content, kind))
.zip(
metadata
.into_iter()
.chain(std::iter::repeat_with(ContentItemMetadata::default)),
)
.map(|((content, kind), metadata)| AnnotatedContent {
content,
kind,
metadata,
})
.collect(),
)
}
/// Replaces a message's content and positional classifications together.
/// Replaces content and its positional annotations together.
pub fn set_annotated_content(
item: &mut ResponseItem,
annotated_content: Vec<AnnotatedContent>,
@@ -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(())
}
+50 -16
View File
@@ -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<RenderedFragment> 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<RenderedFragment> 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<Self>
where
Self: Sized,
{
AttributedFragment {
fragment: self,
metadata,
}
}
/// Returns a stable `<feature>.<name>` 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<F> {
fragment: F,
metadata: ContentItemMetadata,
}
impl<F: ContextualUserFragment> ContextualUserFragment for AttributedFragment<F> {
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;
+2
View File
@@ -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;
+23 -4
View File
@@ -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 {
+6
View File
@@ -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();
+1
View File
@@ -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 {
+3
View File
@@ -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())
@@ -171,6 +171,7 @@ fn detects_hook_prompt_fragment_and_roundtrips_escaping() {
vec![HookPromptFragment {
text: r#"Retry with "waves" & <tides>"#.to_string(),
hook_run_id: "hook-run-1".to_string(),
metadata: Default::default(),
}],
);
assert!(!text.contains("&quot;waves&quot; & <tides>"));
@@ -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<Utc>,
@@ -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())
}
@@ -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())
}
@@ -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<String>) -> 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()
}
}
@@ -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<Uuid>,
window_id: Uuid,
thread_hint: Option<String>,
}
impl TokenBudgetContext {
@@ -26,16 +29,39 @@ impl TokenBudgetContext {
first_window_id: Uuid,
previous_window_id: Option<Uuid>,
window_id: Uuid,
thread_hint: Option<String>,
) -> 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<AnnotatedContent>) -> 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"))
}
}
@@ -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())
}
@@ -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())
}
@@ -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()
}
@@ -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<ContentItemNamespace>,
}
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<ContentItemNamespace>) -> 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())
}
@@ -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<MultiAgentMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage_hint_hash: Option<WorldStateHash>,
#[serde(skip)]
namespace: Option<ContentItemNamespace>,
}
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<dyn ContextualUserFragment>);
let fragment = MultiAgentModeInstructions::from_mode(mode).map(|instructions| {
Box::new(instructions.with_metadata(ContentItemMetadata::tool(self.namespace.clone())))
as Box<dyn ContextualUserFragment>
});
(
Some(self.clone()),
WorldStateUpdate::optional_boxed_fragment(fragment),
@@ -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)
)),
],
);
}
@@ -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<ContentItemNamespace>,
}
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()),
)))
}
}
};
+16 -8
View File
@@ -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,
))),
)],
)
}
}
@@ -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();
@@ -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
@@ -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",
("<extension_test>", "</extension_test>"),
"after",
)),
Some(
RenderedWorldStateFragment::new(
"developer",
("<extension_test>", "</extension_test>"),
"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<_>>(),
vec![(
"developer",
AnnotatedContent::input_text(
AnnotatedContent::text(
"<extension_test>after</extension_test>",
ContentItemKind("extension_test.instructions".to_string()),
codex_protocol::models::ContentItemMetadata::skills(),
),
)]
);
@@ -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()
},
),
+5 -14
View File
@@ -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<RenderedFragment>) -> Option<ResponseItem> {
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<WorldStateUpdate>) -> Vec<ResponseItem> {
@@ -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()
+2
View File
@@ -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(),
}]
);
}
+8 -3
View File
@@ -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<String>> {
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
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()
}
+12 -7
View File
@@ -75,7 +75,7 @@ use crate::turn_metadata::ExecutionMetadata;
pub(crate) struct HookRuntimeOutcome {
pub should_stop: bool,
pub additional_contexts: Vec<String>,
pub additional_contexts: Vec<codex_hooks::HookContext>,
}
pub(crate) enum PreToolUseHookResult {
@@ -718,7 +718,7 @@ pub(crate) async fn record_pending_input(
turn_context: &Arc<TurnContext>,
model_info: &ModelInfo,
pending_input: TurnInput,
additional_contexts: Vec<String>,
additional_contexts: Vec<codex_hooks::HookContext>,
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::<Vec<_>>();
if before_user_prompt {
@@ -849,7 +852,7 @@ impl HookRuntimeOutcome {
pub(crate) async fn record_additional_contexts(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
additional_contexts: Vec<String>,
additional_contexts: Vec<codex_hooks::HookContext>,
) {
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<String>) -> Vec<ResponseItem> {
fn additional_context_messages(
additional_contexts: Vec<codex_hooks::HookContext>,
) -> Vec<ResponseItem> {
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);
+2 -1
View File
@@ -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(),
);
}
}
@@ -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()
},
),
+45 -20
View File
@@ -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::<RenderedFragment>::with_capacity(8);
let mut contextual_user_sections = Vec::<RenderedFragment>::with_capacity(2);
let mut separate_developer_sections = Vec::<RenderedFragment>::new();
let mut separate_developer_messages = Vec::<ResponseItem>::new();
let mut context_window_hints = Vec::new();
let (session_source, auto_compact_window_ids) = {
let state = self.state.lock().await;
@@ -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::<Vec<_>>()
.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![
+14
View File
@@ -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()
}),
};
+18 -4
View File
@@ -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(
@@ -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()
}
),
+3 -1
View File
@@ -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"
);
@@ -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()
+17 -15
View File
@@ -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());
+2 -1
View File
@@ -76,8 +76,9 @@ fn model_token_budget_config() -> ModelTokenBudgetConfig {
fn token_budget_contexts(request: &ResponsesRequest) -> Vec<String> {
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()
}
@@ -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<String>, 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<String>,
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<PromptFragment> for RenderedFragment {
fn from(fragment: PromptFragment) -> Self {
Self::new(
"developer",
AnnotatedContent::input_text(fragment.text, fragment.content_kind),
)
Self::new("developer", fragment.content)
}
}
@@ -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
}
@@ -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!(
@@ -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."}]}],
@@ -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",
@@ -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},
}],
},
})
);
+2 -1
View File
@@ -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(),
)]
})
}
@@ -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(),
)]
);
+9
View File
@@ -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"
}
+11 -7
View File
@@ -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 {
+18
View File
@@ -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(),
}
}
}
+11 -15
View File
@@ -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),
+2 -1
View File
@@ -146,7 +146,8 @@ pub(crate) async fn execute_handlers_with_metadata<T: 'static>(
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)
});
}
+5 -3
View File
@@ -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),
@@ -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<String>> {
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
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()
}
+1
View File
@@ -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,
+11 -4
View File
@@ -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<String>> {
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
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<String>> {
fn execute(&self, _call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
async move {
self.started.notify_one();
self.release.notified().await;
Ok(String::new())
Ok(HookMcpOutput {
text: String::new(),
source_tool_namespace: None,
})
}
.boxed()
}
+7 -5
View File
@@ -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<AdditionalContext>,
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<Item = &'a [AdditionalContext]>,
) -> Vec<AdditionalContext> {
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(
+1
View File
@@ -542,6 +542,7 @@ mod tests {
fn run_result(exit_code: Option<i32>, 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,
@@ -117,6 +117,7 @@ fn handler() -> ConfiguredHandler {
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
context_metadata: codex_protocol::models::ContentItemMetadata::harness(),
started_at: 1,
completed_at: 2,
duration_ms: 1,
+6 -2
View File
@@ -40,7 +40,7 @@ pub struct PostToolUseRequest {
pub struct PostToolUseOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_block: bool,
pub additional_contexts: Vec<String>,
pub additional_contexts: Vec<crate::HookContext>,
pub feedback_message: Option<String>,
}
@@ -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<i32>, 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,
+5 -3
View File
@@ -40,7 +40,7 @@ pub struct PreToolUseOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_block: bool,
pub block_reason: Option<String>,
pub additional_contexts: Vec<String>,
pub additional_contexts: Vec<crate::HookContext>,
pub updated_input: Option<Value>,
}
@@ -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<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
context_metadata: codex_protocol::models::ContentItemMetadata::harness(),
started_at: 1,
completed_at: 2,
duration_ms: 1,
@@ -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,
+8 -5
View File
@@ -84,7 +84,7 @@ pub struct SessionStartOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_contexts: Vec<String>,
pub additional_contexts: Vec<crate::HookContext>,
}
#[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<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
context_metadata: codex_protocol::models::ContentItemMetadata::harness(),
started_at: 1,
completed_at: 2,
duration_ms: 1,
+8 -4
View File
@@ -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<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
context_metadata: codex_protocol::models::ContentItemMetadata::harness(),
started_at: 1,
completed_at: 2,
duration_ms: 1,
@@ -37,7 +37,7 @@ pub struct UserPromptSubmitOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_contexts: Vec<String>,
pub additional_contexts: Vec<crate::HookContext>,
}
#[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<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
context_metadata: codex_protocol::models::ContentItemMetadata::harness(),
started_at: 1,
completed_at: 2,
duration_ms: 1,
+3
View File
@@ -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;
+7 -1
View File
@@ -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<String>,
}
/// 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<String>>;
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>>;
}
+8 -8
View File
@@ -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<AdditionalContext>,
) -> Vec<String> {
) -> Vec<HookContext> {
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
+6 -6
View File
@@ -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(())
}
@@ -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(),
),
),
);
@@ -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!("<permissions instructions>{expected_body}</permissions instructions>"),
ContentItemKind("permissions.instructions".to_string()),
ContentItemMetadata::harness(),
),
),
);
+25 -10
View File
@@ -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<ResponseItem> {
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::<Vec<_>>();
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<Res
role: "user".to_string(),
content,
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: metadata
.iter()
.any(|part| part != &ContentItemMetadata::default())
.then_some(InternalChatMessageMetadataPassthrough {
content_item_metadata: Some(metadata),
..Default::default()
}),
})
}
@@ -723,7 +737,7 @@ pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
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<String> {
@@ -863,6 +877,7 @@ mod tests {
HookPromptFragment {
text: "Retry with tests.".to_string(),
hook_run_id: "hook-run-1".to_string(),
metadata: Default::default(),
}
);
}
+23 -1
View File
@@ -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<Vec<ContentItemKind>>,
/// 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<Vec<ContentItemMetadata>>,
// 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> {
@@ -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 {
@@ -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<String> 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<ContentItemNamespace> 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<ContentItemNamespace>,
},
/// 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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// 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<ContentItemProvenance>,
#[serde(default)]
harness_injected: Option<bool>,
#[serde(default)]
source_tool_namespace: Option<String>,
}
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<ContentItemNamespace>) -> Self {
Self {
provenance: ContentItemProvenance::Tool { namespace },
}
}
}
#[cfg(test)]
#[path = "item_metadata_tests.rs"]
mod tests;