mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Track content provenance through prompt assembly and compaction (#53831)
## Why Message roles and content classifications do not identify who supplied each context part. Source attribution needs to survive prompt assembly and history truncation, including messages that combine text from different producers. ## What changed - Add per-part `ContentItemMetadata` for user input, developer instructions, `AGENTS.md`, skills, harness context, command hooks, and tool context with optional callable namespaces. - Carry attribution alongside content and classifications through contextual fragments, extension prompts, hook output, and agent messages. Keep tool-provided context-window hints separate from harness text. - Preserve the source of retained text slices during compaction and attribute generated truncation notices to the harness. Default missing historical attribution to unknown. - Attribute host MCP calls using cached tool namespaces only when catalog revisions remain stable, without starting servers or refreshing catalogs for attribution. - Send the new metadata to Responses Lite and strip it from requests to endpoints that do not support it. ## Testing Extend compaction tests to cover mixed sources, missing or extra metadata entries, and discarded media. Update Responses Lite request assertions to verify endpoint-specific attribution handling. GitOrigin-RevId: b0ebc929b7c60f3ec49d1affbf897312d63edcc8
This commit is contained in:
@@ -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>"))
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
|
||||
}
|
||||
|
||||
/// Catalog identity within one published connection set, including cached declarations.
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) enum BindingCatalogRevision {
|
||||
Ready(ClientToolCatalogRevision),
|
||||
Cached(u64),
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use runtime::McpRuntime;
|
||||
pub use runtime::McpRuntimeContext;
|
||||
pub use runtime::McpRuntimeInput;
|
||||
pub use runtime::McpStartupPolicy;
|
||||
pub use runtime::McpToolCallResult;
|
||||
pub use runtime::apply_http_headers_helper;
|
||||
pub use sandbox_metadata::MCP_SANDBOX_LAUNCHER_META_KEY;
|
||||
pub use sandbox_metadata::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
|
||||
@@ -71,6 +71,14 @@ 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 the cached catalog is missing, changed, or has no entry for 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,
|
||||
@@ -639,6 +647,19 @@ 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, using a cached namespace only if its catalogs stay unchanged.
|
||||
/// Missing or changed catalogs leave the namespace unknown. Attribution never starts
|
||||
/// servers or refreshes their catalogs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn latest_call_tool(
|
||||
&self,
|
||||
@@ -649,8 +670,16 @@ 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();
|
||||
let cached = current
|
||||
.cached_binding
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|cached| Some((cached.catalog_revisions.clone(), cached.binding.upgrade()?)));
|
||||
let result = current
|
||||
.connections
|
||||
.call_tool(
|
||||
server,
|
||||
tool,
|
||||
@@ -660,17 +689,32 @@ 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?;
|
||||
// Host calls use live connections, unlike model calls pinned to a binding.
|
||||
// Check after dispatch so a refresh before or during the call invalidates attribution.
|
||||
// Revisions include client identity and increase on catalog changes. Reading them
|
||||
// does not start servers or refresh catalogs; all catalogs matter for name collisions.
|
||||
let namespace = if let Some((revisions, binding)) = cached
|
||||
&& current
|
||||
.connections
|
||||
.stable_catalog_revisions(
|
||||
/*required_servers*/ &[],
|
||||
/*required_plugins*/ &HashSet::new(),
|
||||
)
|
||||
.await
|
||||
.as_ref()
|
||||
== Some(&revisions)
|
||||
{
|
||||
binding
|
||||
.tool_info(server, tool)
|
||||
.map(|info| info.callable_namespace.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(McpToolCallResult {
|
||||
result,
|
||||
source_tool_namespace: namespace,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn latest_wait_for_server_ready(&self, server: &str, timeout: Duration) -> bool {
|
||||
|
||||
@@ -1,23 +1,95 @@
|
||||
//! Keep content and its positional annotations together during history edits.
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_utils_string::plan_token_truncation;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Applies the string truncator's head/tail selection to the combined text.
|
||||
/// Retained slices keep their kind and source; the generated notice has harness
|
||||
/// provenance. Non-text parts are retained unchanged, without cloning media.
|
||||
pub fn truncate_annotated_text(
|
||||
parts: Vec<AnnotatedContent>,
|
||||
max_tokens: usize,
|
||||
) -> Vec<AnnotatedContent> {
|
||||
let text: String = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part.content() {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text, .. } => {
|
||||
Some(text.as_str())
|
||||
}
|
||||
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let Some(truncation) = plan_token_truncation(&text, max_tokens) else {
|
||||
return parts;
|
||||
};
|
||||
let mut notice = Some(AnnotatedContent::text(
|
||||
truncation.marker,
|
||||
ContentItemKind("unknown".to_string()),
|
||||
ContentItemMetadata::harness(),
|
||||
));
|
||||
let mut result = Vec::with_capacity(parts.len() + 2);
|
||||
let mut offset = 0;
|
||||
for part in parts {
|
||||
let length = match part.content() {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text, .. } => text.len(),
|
||||
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {
|
||||
result.push(part);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let start = offset;
|
||||
offset += length;
|
||||
if offset <= truncation.removed.start || start >= truncation.removed.end {
|
||||
result.push(part);
|
||||
continue;
|
||||
}
|
||||
let prefix_end = truncation.removed.start.saturating_sub(start);
|
||||
let suffix_start = truncation.removed.end.saturating_sub(start).min(length);
|
||||
if prefix_end > 0 {
|
||||
result.push(part.text_slice(0..prefix_end));
|
||||
}
|
||||
if let Some(notice) = notice.take() {
|
||||
result.push(notice);
|
||||
}
|
||||
if suffix_start < length {
|
||||
result.push(part.text_slice(suffix_start..length));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
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 +107,54 @@ 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)
|
||||
}
|
||||
|
||||
// Called only for the UTF-8-aligned text ranges selected by the truncator.
|
||||
fn text_slice(&self, range: std::ops::Range<usize>) -> Self {
|
||||
let content = match &self.content {
|
||||
ContentItem::InputText { text } => ContentItem::InputText {
|
||||
text: text[range].to_owned(),
|
||||
},
|
||||
ContentItem::OutputText { text, .. } => ContentItem::OutputText {
|
||||
text: text[range].to_owned(),
|
||||
// Original offsets and token probabilities do not describe a shortened slice.
|
||||
annotations: None,
|
||||
logprobs: None,
|
||||
},
|
||||
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {
|
||||
unreachable!("only text parts have byte ranges")
|
||||
}
|
||||
};
|
||||
Self {
|
||||
content,
|
||||
kind: self.kind.clone(),
|
||||
metadata: self.metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
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 +169,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 +180,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 +208,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(())
|
||||
}
|
||||
|
||||
@@ -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,22 +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 {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,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;
|
||||
|
||||
@@ -99,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()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,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;
|
||||
|
||||
@@ -8,8 +8,11 @@ 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 annotated_content::truncate_annotated_text;
|
||||
pub use fragment::AttributedFragment;
|
||||
pub use fragment::ContextualUserFragment;
|
||||
pub use fragment::RenderedFragment;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1004,6 +1004,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();
|
||||
|
||||
@@ -1217,6 +1217,7 @@ impl CodexThread {
|
||||
/*requested_timeout*/ None, /*wait_for_server*/ true,
|
||||
)
|
||||
.await
|
||||
.map(|call| call.result)
|
||||
}
|
||||
|
||||
pub fn enabled(&self, feature: Feature) -> bool {
|
||||
|
||||
@@ -34,6 +34,9 @@ use codex_analytics::CompactionStatus;
|
||||
use codex_analytics::CompactionStrategy;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_analytics::now_unix_seconds;
|
||||
use codex_context_fragments::AnnotatedContent;
|
||||
use codex_context_fragments::set_annotated_content;
|
||||
use codex_context_fragments::truncate_annotated_text;
|
||||
use codex_history::CodexHarnessMetadata;
|
||||
use codex_history::ResponseItemEnvelope;
|
||||
use codex_protocol::ResponseItemId;
|
||||
@@ -52,9 +55,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rollout_trace::InferenceTraceContext;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use futures::prelude::*;
|
||||
use tracing::error;
|
||||
|
||||
@@ -565,8 +566,8 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CompactedUserMessage<'a> {
|
||||
// Flattened text is only for the existing budget and truncation policy.
|
||||
// Whole text messages retain their exact content parts and annotations.
|
||||
// Flattened text is only for budgeting and recognizing summaries. The
|
||||
// output is built from original parts, never reconstructed from this text.
|
||||
// Borrow from the history snapshot until selected output is materialized.
|
||||
message: String,
|
||||
original: &'a ResponseItem,
|
||||
@@ -724,41 +725,65 @@ fn build_compacted_history_with_limit(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut passthrough = internal_chat_message_metadata_passthrough.clone();
|
||||
let mut harness_metadata = message.harness_metadata.cloned();
|
||||
let content = if tokens <= remaining
|
||||
let preserve_content = tokens <= remaining
|
||||
&& content
|
||||
.iter()
|
||||
.all(|part| matches!(part, ContentItem::InputText { .. }))
|
||||
{
|
||||
content.clone()
|
||||
} else {
|
||||
// Rebuild only the text fallback; never clone discarded media.
|
||||
if let Some(kinds) = passthrough
|
||||
.as_mut()
|
||||
.and_then(|metadata| metadata.content_item_kinds.as_mut())
|
||||
{
|
||||
*kinds = vec![ContentItemKind("user.text".to_owned())];
|
||||
}
|
||||
vec![ContentItem::InputText {
|
||||
text: truncate_text(&message.message, TruncationPolicy::Tokens(remaining)),
|
||||
}]
|
||||
.all(|part| matches!(part, ContentItem::InputText { .. }));
|
||||
let mut item = ResponseItem::Message {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
id: id.clone(),
|
||||
role: "user".to_owned(),
|
||||
content: if preserve_content {
|
||||
content.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough:
|
||||
internal_chat_message_metadata_passthrough.clone(),
|
||||
};
|
||||
if !preserve_content {
|
||||
// Select text and its annotations by the same source index.
|
||||
// Do not clone discarded images/audio or infer their text's source.
|
||||
let metadata = internal_chat_message_metadata_passthrough.as_ref();
|
||||
let mut parts: Vec<_> = crate::event_mapping::user_input_parts(content)
|
||||
.filter_map(|(index, part)| {
|
||||
let ContentItem::InputText { text } = part else {
|
||||
return None;
|
||||
};
|
||||
let kind = metadata
|
||||
.and_then(|metadata| metadata.content_item_kinds.as_ref())
|
||||
.and_then(|kinds| kinds.get(index))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ContentItemKind("unknown".to_string()));
|
||||
let source = metadata
|
||||
.and_then(|metadata| metadata.content_item_metadata.as_ref())
|
||||
.and_then(|sources| sources.get(index))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Some(AnnotatedContent::text(text, kind, source))
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
// Preserve the existing empty-text fallback for media-only input.
|
||||
parts.push(AnnotatedContent::new(
|
||||
ContentItem::InputText {
|
||||
text: String::new(),
|
||||
},
|
||||
ContentItemKind("unknown".to_string()),
|
||||
));
|
||||
}
|
||||
set_annotated_content(&mut item, truncate_annotated_text(parts, remaining));
|
||||
}
|
||||
if tokens > remaining
|
||||
&& let Some(metadata) = &mut harness_metadata
|
||||
{
|
||||
metadata.mark_retained_sources_incomplete();
|
||||
}
|
||||
selected_messages.push(ResponseItemEnvelope {
|
||||
item: ResponseItem::Message {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
id: id.clone(),
|
||||
role: "user".to_owned(),
|
||||
content,
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: passthrough,
|
||||
},
|
||||
item,
|
||||
metadata: harness_metadata,
|
||||
});
|
||||
if tokens > remaining {
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_login::CodexAuth;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::ResponseItemId;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_protocol::models::ExecutedToolCall;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -356,6 +357,17 @@ fn collect_annotated_user_messages_extracts_user_text_only(
|
||||
|
||||
if !preserve_content {
|
||||
item = user_message(expected_text);
|
||||
if let ResponseItem::Message {
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} = &mut item
|
||||
{
|
||||
*internal_chat_message_metadata_passthrough =
|
||||
Some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let expected = ResponseItemEnvelope {
|
||||
item,
|
||||
@@ -454,13 +466,35 @@ fn collect_user_messages_filters_legacy_warnings() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_token_limited_compacted_history_truncates_overlong_user_messages() {
|
||||
#[test_case(1, None; "legacy")]
|
||||
#[test_case(1, Some(vec![ContentItemMetadata::user()]); "user")]
|
||||
#[test_case(2, Some(vec![ContentItemMetadata::user(); 2]); "multiple user parts")]
|
||||
#[test_case(2, Some(vec![ContentItemMetadata::user(), ContentItemMetadata::harness()]); "mixed sources")]
|
||||
#[test_case(1, Some(vec![ContentItemMetadata::default()]); "unknown")]
|
||||
#[test_case(1, Some(vec![]); "missing entry")]
|
||||
#[test_case(1, Some(vec![ContentItemMetadata::user(); 2]); "extra entry")]
|
||||
fn build_token_limited_compacted_history_truncates_overlong_user_messages(
|
||||
content_parts: usize,
|
||||
attribution: Option<Vec<ContentItemMetadata>>,
|
||||
) {
|
||||
// Use a small truncation limit so the test remains fast while still validating
|
||||
// that oversized user content is truncated.
|
||||
let max_tokens = 16;
|
||||
let big = "word ".repeat(200);
|
||||
let mut original = ResponseItemEnvelope::new(user_message(&big));
|
||||
if let ResponseItem::Message {
|
||||
content,
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} = &mut original.item
|
||||
{
|
||||
*content = vec![ContentItem::InputText { text: big.clone() }; content_parts];
|
||||
*internal_chat_message_metadata_passthrough =
|
||||
Some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_metadata: attribution.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
original
|
||||
.item
|
||||
.set_id(Some(ResponseItemId::with_suffix("msg", "long-user")));
|
||||
@@ -477,7 +511,44 @@ fn build_token_limited_compacted_history_truncates_overlong_user_messages() {
|
||||
let summary_message = &history[1].item;
|
||||
|
||||
let truncated_text = match truncated_message {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
ResponseItem::Message {
|
||||
role,
|
||||
content,
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} if role == "user" => {
|
||||
let source_at = |index| {
|
||||
attribution
|
||||
.as_ref()
|
||||
.and_then(|sources| sources.get(index))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(
|
||||
content,
|
||||
&vec![
|
||||
ContentItem::InputText {
|
||||
text: big[..32].to_string()
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: format!("…{} tokens truncated…", 250 * content_parts - max_tokens)
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: big[big.len() - 32..].to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
internal_chat_message_metadata_passthrough
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.content_item_metadata,
|
||||
Some(vec![
|
||||
source_at(0),
|
||||
ContentItemMetadata::harness(),
|
||||
source_at(content_parts - 1)
|
||||
])
|
||||
);
|
||||
content_items_to_text(content).unwrap_or_default()
|
||||
}
|
||||
other => panic!("unexpected item in history: {other:?}"),
|
||||
@@ -527,19 +598,29 @@ fn build_token_limited_compacted_history_appends_summary_message() {
|
||||
assert_eq!(summary, summary_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compacted_history_preserves_user_message_passthrough_metadata() {
|
||||
#[test_case(false; "legacy")]
|
||||
#[test_case(true; "attributed text with discarded media")]
|
||||
fn build_compacted_history_preserves_user_message_passthrough_metadata(attributed: bool) {
|
||||
let original = serde_json::from_value(json!({
|
||||
"type": "message", "role": "user",
|
||||
"id": ResponseItemId::with_suffix("msg", "user"),
|
||||
"content": [
|
||||
{"type": "input_text", "text": "<image>"},
|
||||
{"type": "input_image", "image_url": "file://image.png"},
|
||||
{"type": "input_text", "text": "</image>"},
|
||||
{"type": "input_text", "text": "first user message"},
|
||||
{"type": "input_audio", "audio_url": "file://audio.wav"}
|
||||
{"type": "input_text", "text": "<audio>"},
|
||||
{"type": "input_audio", "audio_url": "file://audio.wav"},
|
||||
{"type": "input_text", "text": "</audio>"}
|
||||
],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"turn_id": "turn-1",
|
||||
"content_item_kinds": ["user.image", "user.text", "user.audio"]
|
||||
"content_item_kinds": ["unknown", "user.image", "unknown", "user.text", "unknown", "user.audio", "unknown"],
|
||||
"content_item_metadata": attributed.then(|| {
|
||||
let mut sources = vec![ContentItemMetadata::harness(); 7];
|
||||
sources[3] = ContentItemMetadata::user();
|
||||
sources
|
||||
})
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
@@ -572,6 +653,8 @@ fn build_compacted_history_preserves_user_message_passthrough_metadata() {
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"user.text".to_string()
|
||||
)]),
|
||||
content_item_metadata: attributed
|
||||
.then(|| vec![ContentItemMetadata::user()]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(""waves" & <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())
|
||||
}
|
||||
|
||||
@@ -278,6 +278,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()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+24
@@ -74,6 +74,14 @@ None
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": [
|
||||
"tools.incremental_update"
|
||||
],
|
||||
"content_item_metadata": [
|
||||
{
|
||||
"provenance": {
|
||||
"hook": false,
|
||||
"type": "harness"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"role": "developer",
|
||||
@@ -134,6 +142,14 @@ The following namespaces are no longer available. Do not call tools in them unle
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": [
|
||||
"tools.incremental_update"
|
||||
],
|
||||
"content_item_metadata": [
|
||||
{
|
||||
"provenance": {
|
||||
"hook": false,
|
||||
"type": "harness"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"role": "developer",
|
||||
@@ -208,6 +224,14 @@ None
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": [
|
||||
"tools.incremental_update"
|
||||
],
|
||||
"content_item_metadata": [
|
||||
{
|
||||
"provenance": {
|
||||
"hook": false,
|
||||
"type": "harness"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"role": "developer",
|
||||
|
||||
@@ -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,
|
||||
))),
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_prompts::ResolvedIncrementalToolMessages;
|
||||
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,11 @@ impl WorldStateSection for TopLevelToolsState {
|
||||
text
|
||||
};
|
||||
if !text.is_empty() {
|
||||
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()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,9 +458,13 @@ fn namespace_description_change_does_not_repeat_unchanged_tools(
|
||||
if expected.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ContextualUserFragment::into(DeveloperInstructions::new(
|
||||
expected,
|
||||
))]
|
||||
vec![ContextualUserFragment::into(
|
||||
DeveloperInstructions::new(expected).with_metadata(
|
||||
codex_protocol::models::ContentItemMetadata::tool(Some(
|
||||
codex_protocol::models::ContentItemNamespace::Functions,
|
||||
)),
|
||||
),
|
||||
)]
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -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;
|
||||
@@ -1297,6 +1299,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()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -4,29 +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 {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
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 {
|
||||
status: None,
|
||||
encrypted_content: None,
|
||||
@@ -57,6 +59,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()
|
||||
|
||||
@@ -95,6 +95,36 @@ fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// User-input parts with their original indices. Media wrapper labels are not
|
||||
/// user text; retain the indices so history edits can carry the right annotations.
|
||||
pub(crate) fn user_input_parts(
|
||||
message: &[ContentItem],
|
||||
) -> impl Iterator<Item = (usize, &ContentItem)> {
|
||||
message
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, content_item)| match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
let is_image_label = ((is_local_image_open_tag_text(text)
|
||||
|| is_image_open_tag_text(text))
|
||||
&& matches!(message.get(idx + 1), Some(ContentItem::InputImage { .. })))
|
||||
|| (*idx > 0
|
||||
&& (is_local_image_close_tag_text(text) || is_image_close_tag_text(text))
|
||||
&& matches!(message.get(idx - 1), Some(ContentItem::InputImage { .. })));
|
||||
let is_audio_label = ((is_local_audio_open_tag_text(text)
|
||||
|| is_audio_open_tag_text(text))
|
||||
&& matches!(message.get(idx + 1), Some(ContentItem::InputAudio { .. })))
|
||||
|| (*idx > 0
|
||||
&& (is_local_audio_close_tag_text(text) || is_audio_close_tag_text(text))
|
||||
&& matches!(message.get(idx - 1), Some(ContentItem::InputAudio { .. })));
|
||||
!is_image_label && !is_audio_label
|
||||
}
|
||||
ContentItem::InputImage { .. }
|
||||
| ContentItem::InputAudio { .. }
|
||||
| ContentItem::OutputText { .. } => true,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if is_contextual_user_message_content(message) {
|
||||
return None;
|
||||
@@ -102,24 +132,9 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
|
||||
let mut content: Vec<UserInput> = Vec::new();
|
||||
|
||||
for (idx, content_item) in message.iter().enumerate() {
|
||||
for (_, content_item) in user_input_parts(message) {
|
||||
match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
let is_image_label = ((is_local_image_open_tag_text(text)
|
||||
|| is_image_open_tag_text(text))
|
||||
&& matches!(message.get(idx + 1), Some(ContentItem::InputImage { .. })))
|
||||
|| (idx > 0
|
||||
&& (is_local_image_close_tag_text(text) || is_image_close_tag_text(text))
|
||||
&& matches!(message.get(idx - 1), Some(ContentItem::InputImage { .. })));
|
||||
let is_audio_label = ((is_local_audio_open_tag_text(text)
|
||||
|| is_audio_open_tag_text(text))
|
||||
&& matches!(message.get(idx + 1), Some(ContentItem::InputAudio { .. })))
|
||||
|| (idx > 0
|
||||
&& (is_local_audio_close_tag_text(text) || is_audio_close_tag_text(text))
|
||||
&& matches!(message.get(idx - 1), Some(ContentItem::InputAudio { .. })));
|
||||
if is_image_label || is_audio_label {
|
||||
continue;
|
||||
}
|
||||
content.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
// Model input content does not carry UI element ranges.
|
||||
|
||||
@@ -453,6 +453,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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -490,6 +491,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(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -144,6 +146,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()
|
||||
}
|
||||
),
|
||||
@@ -425,6 +428,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()
|
||||
}),
|
||||
);
|
||||
@@ -481,6 +486,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()
|
||||
},
|
||||
),
|
||||
@@ -536,6 +542,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()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -131,6 +131,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;
|
||||
@@ -3598,6 +3600,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()
|
||||
});
|
||||
}
|
||||
@@ -4045,7 +4048,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,
|
||||
@@ -4448,7 +4475,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;
|
||||
@@ -4505,7 +4532,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());
|
||||
@@ -4543,7 +4570,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(
|
||||
@@ -4559,8 +4586,9 @@ impl Session {
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|result| {
|
||||
let text = result
|
||||
.and_then(|call| {
|
||||
let text = call
|
||||
.result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| {
|
||||
@@ -4569,12 +4597,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()
|
||||
@@ -4582,9 +4614,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();
|
||||
@@ -4624,13 +4655,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()),
|
||||
@@ -4651,13 +4682,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![
|
||||
|
||||
@@ -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;
|
||||
@@ -2783,6 +2784,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
|
||||
@@ -3174,6 +3180,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()
|
||||
},
|
||||
),
|
||||
@@ -4162,6 +4173,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()
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -174,7 +175,6 @@ impl Session {
|
||||
window_ids.first_window_id,
|
||||
window_ids.previous_window_id,
|
||||
window_ids.window_id,
|
||||
/*thread_hint*/ None,
|
||||
));
|
||||
}
|
||||
let guidance = step_context
|
||||
@@ -358,11 +358,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);
|
||||
}
|
||||
@@ -399,7 +408,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(
|
||||
|
||||
@@ -28,6 +28,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;
|
||||
@@ -3093,6 +3094,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()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -5909,6 +5909,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?;
|
||||
|
||||
@@ -5968,6 +5969,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
|
||||
@@ -5983,7 +5985,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"
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ use anyhow::Result;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::compact::content_items_to_text;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ThreadStoreConfig;
|
||||
use codex_extension_api::ExtensionFuture;
|
||||
@@ -886,11 +887,10 @@ async fn standalone_fork_retains_inherited_user_instructions(
|
||||
.items()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
// Compaction keeps the retained text and truncation notice in separate parts.
|
||||
let text = content_items_to_text(content)?;
|
||||
text.starts_with("You may publish the release.")
|
||||
.then(|| text.clone())
|
||||
.then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,11 @@
|
||||
// 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::ContextualUserFragment;
|
||||
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 +15,48 @@ 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.
|
||||
/// Places a contextual fragment in the prompt, preserving its text, kind, and source.
|
||||
/// The slot determines placement within the developer message, regardless of its original role.
|
||||
pub fn from_fragment(slot: PromptSlot, fragment: &impl ContextualUserFragment) -> Self {
|
||||
Self {
|
||||
slot,
|
||||
content: AnnotatedContent::text(
|
||||
fragment.render(),
|
||||
fragment.content_kind(),
|
||||
fragment.content_metadata(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +77,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_exec_server_protocol::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server_protocol::ExecutorCapabilityDiscoveryV2Snapshot;
|
||||
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;
|
||||
@@ -50,6 +51,7 @@ pub struct RenderedWorldStateFragment {
|
||||
role: &'static str,
|
||||
markers: (&'static str, &'static str),
|
||||
body: String,
|
||||
metadata: ContentItemMetadata,
|
||||
}
|
||||
|
||||
impl RenderedWorldStateFragment {
|
||||
@@ -62,9 +64,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!(
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_http_client::HttpClientBuilder;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -21,7 +22,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"}
|
||||
@@ -57,7 +62,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(),
|
||||
@@ -220,7 +229,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."}]}],
|
||||
|
||||
@@ -1387,6 +1387,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",
|
||||
@@ -1429,6 +1432,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",
|
||||
@@ -1515,6 +1521,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",
|
||||
@@ -1953,6 +1962,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",
|
||||
@@ -2142,6 +2154,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",
|
||||
@@ -2839,6 +2854,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",
|
||||
@@ -2885,6 +2903,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",
|
||||
|
||||
@@ -263,6 +263,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},
|
||||
}],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
)]
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_extension_api::ExtensionMetrics;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ExtensionWarning;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::PromptSlot;
|
||||
use codex_extension_api::SelectedPluginSnapshot;
|
||||
use codex_extension_api::SkillInvocationContributor;
|
||||
use codex_extension_api::SkillInvocationInput;
|
||||
@@ -239,7 +240,7 @@ where
|
||||
rendered
|
||||
.fragment
|
||||
.map(|fragment| {
|
||||
PromptFragment::developer_capability(fragment.render(), fragment.content_kind())
|
||||
PromptFragment::from_fragment(PromptSlot::DeveloperCapabilities, &fragment)
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -2240,7 +2241,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
|
||||
@@ -2248,7 +2249,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()
|
||||
}
|
||||
@@ -2655,11 +2659,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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -693,7 +701,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()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -725,7 +739,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> {
|
||||
@@ -865,6 +879,7 @@ mod tests {
|
||||
HookPromptFragment {
|
||||
text: "Retry with tests.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
metadata: Default::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,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_completeness;
|
||||
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(
|
||||
@@ -986,6 +989,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.
|
||||
@@ -1492,7 +1501,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;
|
||||
@@ -1502,11 +1511,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> {
|
||||
|
||||
@@ -37,6 +37,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;
|
||||
|
||||
@@ -3,9 +3,11 @@ mod truncate;
|
||||
|
||||
pub use json::to_ascii_json_string;
|
||||
pub use json::to_json_string_bounded;
|
||||
pub use truncate::TextTruncation;
|
||||
pub use truncate::approx_bytes_for_tokens;
|
||||
pub use truncate::approx_token_count;
|
||||
pub use truncate::approx_tokens_from_byte_count;
|
||||
pub use truncate::plan_token_truncation;
|
||||
pub use truncate::truncate_middle_chars;
|
||||
pub use truncate::truncate_middle_with_marker;
|
||||
pub use truncate::truncate_middle_with_token_budget;
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
//! Utilities for truncating large chunks of output while preserving a prefix
|
||||
//! and suffix on UTF-8 boundaries.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
const APPROX_BYTES_PER_TOKEN: usize = 4;
|
||||
|
||||
/// Replaces one UTF-8-aligned range with a notice. Callers can apply the same
|
||||
/// edit to annotated text without losing the sources of the retained slices.
|
||||
pub struct TextTruncation {
|
||||
pub removed: Range<usize>,
|
||||
pub marker: String,
|
||||
}
|
||||
|
||||
/// Plans the existing head/tail token truncation. The marker is outside the
|
||||
/// retained-text budget, just as it is in [`truncate_middle_with_token_budget`].
|
||||
pub fn plan_token_truncation(s: &str, max_tokens: usize) -> Option<TextTruncation> {
|
||||
let max_bytes = approx_bytes_for_tokens(max_tokens);
|
||||
if s.len() <= max_bytes {
|
||||
return None;
|
||||
}
|
||||
let (left_budget, right_budget) = split_budget(max_bytes);
|
||||
let removed = s.floor_char_boundary(left_budget)..s.ceil_char_boundary(s.len() - right_budget);
|
||||
let removed_tokens = approx_tokens_from_byte_count(s.len() - max_bytes);
|
||||
Some(TextTruncation {
|
||||
removed,
|
||||
marker: format!("…{removed_tokens} tokens truncated…"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Retain at most `max_bytes` from a string's ends and add a character-count
|
||||
/// marker outside that budget.
|
||||
///
|
||||
/// Use [`truncate_middle_with_marker`] when the complete result must fit a hard
|
||||
/// byte limit.
|
||||
pub fn truncate_middle_chars(s: &str, max_bytes: usize) -> String {
|
||||
truncate_with_byte_estimate(s, max_bytes, /*use_tokens*/ false)
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
let (left_budget, right_budget) = split_budget(max_bytes);
|
||||
let (removed_chars, left, right) = split_string(s, left_budget, right_budget);
|
||||
assemble_truncated_output(left, right, &format!("…{removed_chars} chars truncated…"))
|
||||
}
|
||||
|
||||
/// Truncate the middle of a UTF-8 string so the result, including `marker`, is
|
||||
@@ -37,18 +67,13 @@ pub fn truncate_middle_with_marker(s: &str, max_bytes: usize, marker: &str) -> S
|
||||
/// truncated string and `Some(original_token_count)` if truncation occurred;
|
||||
/// otherwise returns the original string and `None`.
|
||||
pub fn truncate_middle_with_token_budget(s: &str, max_tokens: usize) -> (String, Option<u64>) {
|
||||
if s.is_empty() {
|
||||
return (String::new(), None);
|
||||
}
|
||||
|
||||
if max_tokens > 0 && s.len() <= approx_bytes_for_tokens(max_tokens) {
|
||||
let Some(truncation) = plan_token_truncation(s, max_tokens) else {
|
||||
return (s.to_string(), None);
|
||||
}
|
||||
|
||||
let truncated = truncate_with_byte_estimate(
|
||||
s,
|
||||
approx_bytes_for_tokens(max_tokens),
|
||||
/*use_tokens*/ true,
|
||||
};
|
||||
let truncated = assemble_truncated_output(
|
||||
&s[..truncation.removed.start],
|
||||
&s[truncation.removed.end..],
|
||||
&truncation.marker,
|
||||
);
|
||||
let total_tokens = u64::try_from(approx_token_count(s)).unwrap_or(u64::MAX);
|
||||
|
||||
@@ -59,45 +84,6 @@ pub fn truncate_middle_with_token_budget(s: &str, max_tokens: usize) -> (String,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_with_byte_estimate(s: &str, max_bytes: usize, use_tokens: bool) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if max_bytes == 0 {
|
||||
let total_chars = if use_tokens { 0 } else { s.chars().count() };
|
||||
return format_truncation_marker(
|
||||
use_tokens,
|
||||
removed_units(use_tokens, s.len(), total_chars),
|
||||
);
|
||||
}
|
||||
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let total_bytes = s.len();
|
||||
let (left_budget, right_budget) = split_budget(max_bytes);
|
||||
let (removed_chars, left, right) = if use_tokens {
|
||||
// Token markers depend only on byte counts; avoid scanning the discarded middle.
|
||||
let prefix_end = s.floor_char_boundary(left_budget);
|
||||
let suffix_start = s.ceil_char_boundary(total_bytes - right_budget);
|
||||
(0, &s[..prefix_end], &s[suffix_start..])
|
||||
} else {
|
||||
split_string(s, left_budget, right_budget)
|
||||
};
|
||||
let marker = format_truncation_marker(
|
||||
use_tokens,
|
||||
removed_units(
|
||||
use_tokens,
|
||||
total_bytes.saturating_sub(max_bytes),
|
||||
removed_chars,
|
||||
),
|
||||
);
|
||||
|
||||
assemble_truncated_output(left, right, &marker)
|
||||
}
|
||||
|
||||
pub fn approx_token_count(text: &str) -> usize {
|
||||
let len = text.len();
|
||||
len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN
|
||||
@@ -158,22 +144,6 @@ fn split_budget(budget: usize) -> (usize, usize) {
|
||||
(left, budget - left)
|
||||
}
|
||||
|
||||
fn format_truncation_marker(use_tokens: bool, removed_count: u64) -> String {
|
||||
if use_tokens {
|
||||
format!("…{removed_count} tokens truncated…")
|
||||
} else {
|
||||
format!("…{removed_count} chars truncated…")
|
||||
}
|
||||
}
|
||||
|
||||
fn removed_units(use_tokens: bool, removed_bytes: usize, removed_chars: usize) -> u64 {
|
||||
if use_tokens {
|
||||
approx_tokens_from_byte_count(removed_bytes)
|
||||
} else {
|
||||
u64::try_from(removed_chars).unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble_truncated_output(prefix: &str, suffix: &str, marker: &str) -> String {
|
||||
let mut out = String::with_capacity(prefix.len() + marker.len() + suffix.len() + 1);
|
||||
out.push_str(prefix);
|
||||
|
||||
Reference in New Issue
Block a user