mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Track source attribution for individual context parts (#52195)
## What changed - Add `ContentItemMetadata` to distinguish user input, developer instructions, `AGENTS.md`, skills, harness context, and tool-supplied context independently of message role and content classification. - Keep attribution attached to content through prompt assembly, history processing, and hook-output spilling. Distinguish command hooks from MCP hooks and retain tool namespaces when known. - Treat missing historical attribution as unknown and send per-content metadata only to Responses Lite. ## Testing Update request and history assertions to cover attribution persistence and Responses Lite metadata handling. GitOrigin-RevId: 24e56005f5bdb98bf82f2b3943f29c4c81f3d587
This commit is contained in:
@@ -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>"))
|
||||
|
||||
@@ -27,6 +27,7 @@ pub use runtime::McpRuntime;
|
||||
pub use runtime::McpRuntimeContext;
|
||||
pub use runtime::McpRuntimeInput;
|
||||
pub use runtime::McpStartupPolicy;
|
||||
pub use runtime::McpToolCallResult;
|
||||
pub use runtime::SandboxState;
|
||||
pub use runtime::apply_http_headers_helper;
|
||||
pub use tool_catalog_cache::McpToolCatalogCache;
|
||||
|
||||
@@ -75,6 +75,13 @@ pub enum McpStartupPolicy {
|
||||
LazyWhenCached,
|
||||
}
|
||||
|
||||
/// A host-requested tool result and its identity in the published MCP catalog.
|
||||
pub struct McpToolCallResult {
|
||||
pub result: CallToolResult,
|
||||
/// None when no retained binding identifies the tool. This is not MCP wire metadata.
|
||||
pub source_tool_namespace: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration and owning-thread state needed to materialize an MCP runtime.
|
||||
pub struct McpRuntimeInput {
|
||||
pub startup_policy: McpStartupPolicy,
|
||||
@@ -643,6 +650,18 @@ impl McpRuntime {
|
||||
self.latest_connections().list_all_tools().await
|
||||
}
|
||||
|
||||
pub async fn latest_read_resource(
|
||||
&self,
|
||||
server: &str,
|
||||
params: ReadResourceRequestParams,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
self.latest_connections()
|
||||
.read_resource(server, params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Calls a tool for the host and reports its callable namespace from the same runtime.
|
||||
/// Missing cached identity never blocks execution; its namespace remains unknown.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn latest_call_tool(
|
||||
&self,
|
||||
@@ -653,8 +672,23 @@ impl McpRuntime {
|
||||
meta: Option<serde_json::Value>,
|
||||
requested_timeout: Option<Duration>,
|
||||
wait_for_server: bool,
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.latest_connections()
|
||||
) -> anyhow::Result<McpToolCallResult> {
|
||||
let current = self.current.load_full();
|
||||
// Attribution must never delay a successful call. The binding is immutable;
|
||||
// if it does not describe this host-only tool, keep its namespace unknown.
|
||||
let namespace = current
|
||||
.cached_binding
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.binding.upgrade())
|
||||
.and_then(|binding| {
|
||||
binding
|
||||
.tool_info(server, tool)
|
||||
.map(|info| info.callable_namespace.clone())
|
||||
});
|
||||
let result = current
|
||||
.connections
|
||||
.call_tool(
|
||||
server,
|
||||
tool,
|
||||
@@ -664,17 +698,11 @@ impl McpRuntime {
|
||||
requested_timeout,
|
||||
wait_for_server,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn latest_read_resource(
|
||||
&self,
|
||||
server: &str,
|
||||
params: ReadResourceRequestParams,
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
self.latest_connections()
|
||||
.read_resource(server, params)
|
||||
.await
|
||||
.await?;
|
||||
Ok(McpToolCallResult {
|
||||
result,
|
||||
source_tool_namespace: namespace,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn latest_wait_for_server_ready(&self, server: &str, timeout: Duration) -> bool {
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
/// Model-visible content paired with its harness-owned classification.
|
||||
/// Content, classification and attribution travel together through edits and truncation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnnotatedContent {
|
||||
content: ContentItem,
|
||||
kind: ContentItemKind,
|
||||
metadata: ContentItemMetadata,
|
||||
}
|
||||
|
||||
impl AnnotatedContent {
|
||||
/// Creates content and its classification together.
|
||||
pub fn new(content: ContentItem, kind: ContentItemKind) -> Self {
|
||||
Self { content, kind }
|
||||
Self {
|
||||
content,
|
||||
kind,
|
||||
metadata: ContentItemMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates model-visible input text and its classification together.
|
||||
pub fn input_text(text: impl Into<String>, kind: ContentItemKind) -> Self {
|
||||
Self::new(ContentItem::InputText { text: text.into() }, kind)
|
||||
/// Creates text with the attribution supplied by its producer.
|
||||
pub fn text(
|
||||
text: impl Into<String>,
|
||||
kind: ContentItemKind,
|
||||
metadata: ContentItemMetadata,
|
||||
) -> Self {
|
||||
Self {
|
||||
content: ContentItem::InputText { text: text.into() },
|
||||
kind,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the model-visible content.
|
||||
@@ -35,16 +49,29 @@ impl AnnotatedContent {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// Separates the content from its classification at an API boundary.
|
||||
pub fn into_parts(self) -> (ContentItem, ContentItemKind) {
|
||||
(self.content, self.kind)
|
||||
/// Separates content and its positional annotations at an API boundary.
|
||||
pub fn into_parts(self) -> (ContentItem, ContentItemKind, ContentItemMetadata) {
|
||||
(self.content, self.kind, self.metadata)
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a message's content together with its positional classifications.
|
||||
/// Assembles a message without separating its content from its attribution.
|
||||
pub fn message_from_parts(role: impl Into<String>, parts: Vec<AnnotatedContent>) -> ResponseItem {
|
||||
let mut item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.into(),
|
||||
content: Vec::new(),
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
set_annotated_content(&mut item, parts);
|
||||
item
|
||||
}
|
||||
|
||||
/// Takes a message's content together with its classification and source metadata.
|
||||
///
|
||||
/// Legacy messages, including persisted rollouts, may not have classifications.
|
||||
/// Missing entries are classified as unknown so the message remains usable.
|
||||
/// Missing entries have unknown classification and source so the message remains usable.
|
||||
pub fn to_annotated_content(item: &mut ResponseItem) -> Option<Vec<AnnotatedContent>> {
|
||||
let ResponseItem::Message {
|
||||
content,
|
||||
@@ -59,6 +86,10 @@ pub fn to_annotated_content(item: &mut ResponseItem) -> Option<Vec<AnnotatedCont
|
||||
.as_mut()
|
||||
.and_then(|metadata| metadata.content_item_kinds.take())
|
||||
.unwrap_or_default();
|
||||
let metadata = internal_chat_message_metadata_passthrough
|
||||
.as_mut()
|
||||
.and_then(|metadata| metadata.content_item_metadata.take())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(
|
||||
std::mem::take(content)
|
||||
@@ -66,12 +97,21 @@ pub fn to_annotated_content(item: &mut ResponseItem) -> Option<Vec<AnnotatedCont
|
||||
.zip(kinds.into_iter().chain(std::iter::repeat_with(|| {
|
||||
ContentItemKind("unknown".to_string())
|
||||
})))
|
||||
.map(|(content, kind)| AnnotatedContent::new(content, kind))
|
||||
.zip(
|
||||
metadata
|
||||
.into_iter()
|
||||
.chain(std::iter::repeat_with(ContentItemMetadata::default)),
|
||||
)
|
||||
.map(|((content, kind), metadata)| AnnotatedContent {
|
||||
content,
|
||||
kind,
|
||||
metadata,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces a message's content and positional classifications together.
|
||||
/// Replaces content and its positional annotations together.
|
||||
pub fn set_annotated_content(
|
||||
item: &mut ResponseItem,
|
||||
annotated_content: Vec<AnnotatedContent>,
|
||||
@@ -85,14 +125,22 @@ pub fn set_annotated_content(
|
||||
return None;
|
||||
};
|
||||
|
||||
let (updated_content, content_item_kinds): (Vec<_>, Vec<_>) = annotated_content
|
||||
.into_iter()
|
||||
.map(AnnotatedContent::into_parts)
|
||||
.unzip();
|
||||
let mut updated_content = Vec::with_capacity(annotated_content.len());
|
||||
let mut content_item_kinds = Vec::with_capacity(annotated_content.len());
|
||||
let mut content_item_metadata = Vec::with_capacity(annotated_content.len());
|
||||
for annotated in annotated_content {
|
||||
let (content, kind, metadata) = annotated.into_parts();
|
||||
updated_content.push(content);
|
||||
content_item_kinds.push(kind);
|
||||
content_item_metadata.push(metadata);
|
||||
}
|
||||
*content = updated_content;
|
||||
internal_chat_message_metadata_passthrough
|
||||
.get_or_insert_default()
|
||||
.content_item_kinds = Some(content_item_kinds);
|
||||
let metadata = internal_chat_message_metadata_passthrough.get_or_insert_default();
|
||||
metadata.content_item_kinds = Some(content_item_kinds);
|
||||
metadata.content_item_metadata = content_item_metadata
|
||||
.iter()
|
||||
.any(|metadata| metadata != &ContentItemMetadata::default())
|
||||
.then_some(content_item_metadata);
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::AnnotatedContent;
|
||||
use crate::message_from_parts;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
/// A rendered contextual fragment and the role that owns its annotated content.
|
||||
@@ -35,20 +36,7 @@ impl RenderedFragment {
|
||||
impl From<RenderedFragment> for ResponseItem {
|
||||
fn from(fragment: RenderedFragment) -> Self {
|
||||
let (role, annotated_content) = fragment.into_parts();
|
||||
let (content, content_kind) = annotated_content.into_parts();
|
||||
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
content: vec![content],
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: Some(
|
||||
InternalChatMessageMetadataPassthrough {
|
||||
content_item_kinds: Some(vec![content_kind]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
}
|
||||
message_from_parts(role, vec![annotated_content])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +52,22 @@ impl From<RenderedFragment> for ResponseItem {
|
||||
pub trait ContextualUserFragment {
|
||||
fn role(&self) -> &'static str;
|
||||
|
||||
/// Attribution is supplied when the fragment is produced, never inferred from rendered text.
|
||||
fn content_metadata(&self) -> ContentItemMetadata {
|
||||
ContentItemMetadata::harness()
|
||||
}
|
||||
|
||||
/// Binds producer attribution before this fragment is rendered or persisted.
|
||||
fn with_metadata(self, metadata: ContentItemMetadata) -> AttributedFragment<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
AttributedFragment {
|
||||
fragment: self,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a stable `<feature>.<name>` classification, using `generic` for shared fragments.
|
||||
fn content_kind(&self) -> ContentItemKind;
|
||||
|
||||
@@ -97,7 +101,7 @@ pub trait ContextualUserFragment {
|
||||
fn render_fragment(&self) -> RenderedFragment {
|
||||
RenderedFragment::new(
|
||||
self.role(),
|
||||
AnnotatedContent::input_text(self.render(), self.content_kind()),
|
||||
AnnotatedContent::text(self.render(), self.content_kind(), self.content_metadata()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +117,36 @@ pub trait ContextualUserFragment {
|
||||
}
|
||||
}
|
||||
|
||||
/// A contextual fragment with attribution supplied by its producer.
|
||||
pub struct AttributedFragment<F> {
|
||||
fragment: F,
|
||||
metadata: ContentItemMetadata,
|
||||
}
|
||||
|
||||
impl<F: ContextualUserFragment> ContextualUserFragment for AttributedFragment<F> {
|
||||
fn role(&self) -> &'static str {
|
||||
self.fragment.role()
|
||||
}
|
||||
fn content_kind(&self) -> ContentItemKind {
|
||||
self.fragment.content_kind()
|
||||
}
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
self.fragment.markers()
|
||||
}
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
F::type_markers()
|
||||
}
|
||||
fn body(&self) -> String {
|
||||
self.fragment.body()
|
||||
}
|
||||
fn render(&self) -> String {
|
||||
self.fragment.render()
|
||||
}
|
||||
fn content_metadata(&self) -> ContentItemMetadata {
|
||||
self.metadata.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
|
||||
if start_marker.is_empty() || end_marker.is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -8,8 +8,10 @@ mod recap_prompt;
|
||||
pub use additional_context::AdditionalContextDeveloperFragment;
|
||||
pub use additional_context::AdditionalContextUserFragment;
|
||||
pub use annotated_content::AnnotatedContent;
|
||||
pub use annotated_content::message_from_parts;
|
||||
pub use annotated_content::set_annotated_content;
|
||||
pub use annotated_content::to_annotated_content;
|
||||
pub use fragment::AttributedFragment;
|
||||
pub use fragment::ContextualUserFragment;
|
||||
pub use fragment::RenderedFragment;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -996,6 +996,12 @@ impl ModelClient {
|
||||
prefix.push(instructions);
|
||||
}
|
||||
input.splice(0..0, prefix);
|
||||
if !model_info.use_responses_lite {
|
||||
// Only RLite accepts per-content attribution until the Responses API schema expands.
|
||||
for item in &mut input {
|
||||
item.clear_content_item_metadata();
|
||||
}
|
||||
}
|
||||
if !is_openai {
|
||||
for item in &mut input {
|
||||
item.clear_internal_chat_message_metadata_passthrough();
|
||||
|
||||
@@ -1209,6 +1209,7 @@ impl CodexThread {
|
||||
/*requested_timeout*/ None, /*wait_for_server*/ true,
|
||||
)
|
||||
.await
|
||||
.map(|call| call.result)
|
||||
}
|
||||
|
||||
pub fn enabled(&self, feature: Feature) -> bool {
|
||||
|
||||
@@ -716,6 +716,9 @@ fn build_compacted_history_with_limit(
|
||||
content.clone()
|
||||
} else {
|
||||
// Rebuild only the text fallback; never clone discarded media.
|
||||
if let Some(metadata) = &mut passthrough {
|
||||
metadata.content_item_metadata = None;
|
||||
}
|
||||
if let Some(kinds) = passthrough
|
||||
.as_mut()
|
||||
.and_then(|metadata| metadata.content_item_kinds.as_mut())
|
||||
|
||||
@@ -171,6 +171,7 @@ fn detects_hook_prompt_fragment_and_roundtrips_escaping() {
|
||||
vec![HookPromptFragment {
|
||||
text: r#"Retry with "waves" & <tides>"#.to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
metadata: Default::default(),
|
||||
}],
|
||||
);
|
||||
assert!(!text.contains(""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())
|
||||
}
|
||||
|
||||
@@ -277,6 +277,10 @@ struct WorldStateContextFragment {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for WorldStateContextFragment {
|
||||
fn content_metadata(&self) -> codex_protocol::models::ContentItemMetadata {
|
||||
self.fragment.metadata().clone()
|
||||
}
|
||||
|
||||
fn content_kind(&self) -> ContentItemKind {
|
||||
self.content_kind.clone()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::agent::child_config::model_supports_multi_agent_backend;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::environment_context::push_xml_escaped_text;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
|
||||
@@ -24,6 +26,7 @@ const OMITTED_NOTICE: &str = "Additional model choices omitted.\n";
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ModelCatalogState {
|
||||
catalog: String,
|
||||
namespace: Option<ContentItemNamespace>,
|
||||
}
|
||||
|
||||
impl ModelCatalogState {
|
||||
@@ -82,11 +85,23 @@ impl ModelCatalogState {
|
||||
catalog.push_str(&escaped);
|
||||
catalog.push('\n');
|
||||
}
|
||||
Self { catalog }
|
||||
Self {
|
||||
catalog,
|
||||
namespace: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_namespace(mut self, namespace: Option<ContentItemNamespace>) -> Self {
|
||||
self.namespace = namespace;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ModelCatalogState {
|
||||
fn content_metadata(&self) -> ContentItemMetadata {
|
||||
ContentItemMetadata::tool(self.namespace.clone())
|
||||
}
|
||||
|
||||
fn content_kind(&self) -> ContentItemKind {
|
||||
ContentItemKind("multi_agent.model_catalog".to_string())
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use super::multi_agent_usage_hint::MultiAgentUsageHintState;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use serde::Deserialize;
|
||||
@@ -20,6 +22,8 @@ pub(crate) struct MultiAgentModeState {
|
||||
mode: Option<MultiAgentMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage_hint_hash: Option<WorldStateHash>,
|
||||
#[serde(skip)]
|
||||
namespace: Option<ContentItemNamespace>,
|
||||
}
|
||||
|
||||
impl MultiAgentModeState {
|
||||
@@ -33,9 +37,15 @@ impl MultiAgentModeState {
|
||||
mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode,
|
||||
}),
|
||||
usage_hint_hash: None,
|
||||
namespace: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_namespace(mut self, namespace: ContentItemNamespace) -> Self {
|
||||
self.namespace = Some(namespace);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self {
|
||||
self.usage_hint_hash = Some(usage_hint.fingerprint.clone());
|
||||
self
|
||||
@@ -81,8 +91,10 @@ impl WorldStateSection for MultiAgentModeState {
|
||||
}
|
||||
};
|
||||
|
||||
let fragment = MultiAgentModeInstructions::from_mode(mode)
|
||||
.map(|instructions| Box::new(instructions) as Box<dyn ContextualUserFragment>);
|
||||
let fragment = MultiAgentModeInstructions::from_mode(mode).map(|instructions| {
|
||||
Box::new(instructions.with_metadata(ContentItemMetadata::tool(self.namespace.clone())))
|
||||
as Box<dyn ContextualUserFragment>
|
||||
});
|
||||
(
|
||||
Some(self.clone()),
|
||||
WorldStateUpdate::optional_boxed_fragment(fragment),
|
||||
|
||||
@@ -136,8 +136,12 @@ fn catalog_role_updates_remain_separate_from_active_mode() {
|
||||
assert_eq!(
|
||||
updates,
|
||||
vec![
|
||||
ContextualUserFragment::into(current_role),
|
||||
ContextualUserFragment::into(expected_mode),
|
||||
ContextualUserFragment::into(current_role.with_metadata(
|
||||
codex_protocol::models::ContentItemMetadata::tool(/*namespace*/ None)
|
||||
)),
|
||||
ContextualUserFragment::into(expected_mode.with_metadata(
|
||||
codex_protocol::models::ContentItemMetadata::tool(/*namespace*/ None)
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ use super::WorldStateUpdate;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::MultiAgentRoleInstructions;
|
||||
use crate::context::MultiAgentUsageHint;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
|
||||
/// Configured or model-owned multi-agent instructions currently visible to the model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MultiAgentUsageHintState {
|
||||
instructions: MultiAgentRoleInstructions,
|
||||
pub(super) fingerprint: WorldStateHash,
|
||||
namespace: Option<ContentItemNamespace>,
|
||||
}
|
||||
|
||||
impl MultiAgentUsageHintState {
|
||||
@@ -20,8 +23,14 @@ impl MultiAgentUsageHintState {
|
||||
Self {
|
||||
instructions,
|
||||
fingerprint,
|
||||
namespace: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_namespace(mut self, namespace: ContentItemNamespace) -> Self {
|
||||
self.namespace = Some(namespace);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for MultiAgentUsageHintState {
|
||||
@@ -43,11 +52,14 @@ impl WorldStateSection for MultiAgentUsageHintState {
|
||||
PreviousSectionState::Unknown => None,
|
||||
PreviousSectionState::Known(_) | PreviousSectionState::Absent => {
|
||||
if self.instructions.markers().0.is_empty() {
|
||||
Some(Box::new(MultiAgentUsageHint::new(
|
||||
&self.instructions.body(),
|
||||
)))
|
||||
Some(Box::new(
|
||||
MultiAgentUsageHint::new(&self.instructions.body())
|
||||
.with_metadata(ContentItemMetadata::tool(self.namespace.clone())),
|
||||
))
|
||||
} else {
|
||||
Some(Box::new(self.instructions.clone()))
|
||||
Some(Box::new(self.instructions.clone().with_metadata(
|
||||
ContentItemMetadata::tool(self.namespace.clone()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,12 +8,15 @@ use super::SectionTransition;
|
||||
use super::WorldStateContextFragment;
|
||||
use super::WorldStateSection;
|
||||
use super::WorldStateUpdate;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_extension_api::ExtensionMetrics;
|
||||
use codex_extension_api::RenderedWorldStateFragment;
|
||||
use codex_otel::THREAD_TOOLS_FRAGMENT_BYTES_METRIC;
|
||||
use codex_otel::THREAD_TOOLS_METRIC_BUCKETS;
|
||||
use codex_otel::THREAD_TOOLS_NAMESPACES_TOTAL_METRIC;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
use codex_protocol::protocol::TOOLS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::TOOLS_OPEN_TAG;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -138,14 +141,19 @@ impl WorldStateSection for ToolsState {
|
||||
record_fragment_metrics(self.metrics.as_ref(), previous, &rendered);
|
||||
(
|
||||
Some(current),
|
||||
vec![WorldStateUpdate::fragment(WorldStateContextFragment {
|
||||
fragment: RenderedWorldStateFragment::new(
|
||||
"developer",
|
||||
(TOOLS_OPEN_TAG, TOOLS_CLOSE_TAG),
|
||||
rendered.body,
|
||||
),
|
||||
content_kind: ContentItemKind("tools.deferred_namespaces".to_string()),
|
||||
})],
|
||||
vec![WorldStateUpdate::fragment(
|
||||
WorldStateContextFragment {
|
||||
fragment: RenderedWorldStateFragment::new(
|
||||
"developer",
|
||||
(TOOLS_OPEN_TAG, TOOLS_CLOSE_TAG),
|
||||
rendered.body,
|
||||
),
|
||||
content_kind: ContentItemKind("tools.deferred_namespaces".to_string()),
|
||||
}
|
||||
.with_metadata(ContentItemMetadata::tool(Some(
|
||||
ContentItemNamespace::ToolSearch,
|
||||
))),
|
||||
)],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_otel::TOOL_INCREMENTAL_UPDATES_METRIC;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -187,8 +188,10 @@ impl WorldStateSection for TopLevelToolsState {
|
||||
} else {
|
||||
format!("Updated instructions for the {name} namespace:\n{instructions}")
|
||||
};
|
||||
namespace_updates
|
||||
.push(WorldStateUpdate::fragment(DeveloperInstructions::new(text)));
|
||||
namespace_updates.push(WorldStateUpdate::fragment(
|
||||
DeveloperInstructions::new(text)
|
||||
.with_metadata(ContentItemMetadata::tool(Some(name.to_string().into()))),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
|
||||
@@ -304,9 +304,14 @@ fn namespace_description_change_does_not_repeat_unchanged_tools() {
|
||||
let (snapshot, updates) = current.render_diff(PreviousSectionState::Known(&snapshot));
|
||||
assert_eq!(
|
||||
merge_world_state_updates(updates),
|
||||
vec![ContextualUserFragment::into(DeveloperInstructions::new(
|
||||
"Updated instructions for the functions namespace:\nUpdated namespace guidance."
|
||||
))]
|
||||
vec![ContextualUserFragment::into(
|
||||
DeveloperInstructions::new(
|
||||
"Updated instructions for the functions namespace:\nUpdated namespace guidance."
|
||||
)
|
||||
.with_metadata(codex_protocol::models::ContentItemMetadata::tool(Some(
|
||||
codex_protocol::models::ContentItemNamespace::Functions
|
||||
)))
|
||||
)]
|
||||
);
|
||||
assert!(
|
||||
current
|
||||
|
||||
@@ -192,11 +192,14 @@ fn extension_owned_section_uses_its_stable_id_as_content_kind_feature() {
|
||||
world_state.add_extension_section(WorldStateSectionContribution::new("extension_test", |_| {
|
||||
(
|
||||
Some(json!({"value": "after"})),
|
||||
Some(RenderedWorldStateFragment::new(
|
||||
"developer",
|
||||
("<extension_test>", "</extension_test>"),
|
||||
"after",
|
||||
)),
|
||||
Some(
|
||||
RenderedWorldStateFragment::new(
|
||||
"developer",
|
||||
("<extension_test>", "</extension_test>"),
|
||||
"after",
|
||||
)
|
||||
.with_metadata(codex_protocol::models::ContentItemMetadata::skills()),
|
||||
),
|
||||
)
|
||||
}));
|
||||
|
||||
@@ -211,9 +214,10 @@ fn extension_owned_section_uses_its_stable_id_as_content_kind_feature() {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(
|
||||
"developer",
|
||||
AnnotatedContent::input_text(
|
||||
AnnotatedContent::text(
|
||||
"<extension_test>after</extension_test>",
|
||||
ContentItemKind("extension_test.instructions".to_string()),
|
||||
codex_protocol::models::ContentItemMetadata::skills(),
|
||||
),
|
||||
)]
|
||||
);
|
||||
|
||||
@@ -46,6 +46,8 @@ use image::Luma;
|
||||
use image::Rgba;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::from_value;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
|
||||
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
|
||||
@@ -1264,6 +1266,10 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
|
||||
ContentItemKind("audio.unsupported".to_string()),
|
||||
ContentItemKind("user.text".to_string()),
|
||||
]),
|
||||
content_item_metadata: from_value(json!([
|
||||
{}, {"harness_injected": true}, {"harness_injected": true}, {}
|
||||
]))
|
||||
.unwrap(),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -4,27 +4,18 @@ use crate::context::world_state::Placement;
|
||||
use crate::context::world_state::WorldStateUpdate;
|
||||
use crate::context::world_state::WorldStateUpdateContent;
|
||||
use codex_context_fragments::RenderedFragment;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use codex_context_fragments::message_from_parts;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub(crate) fn build_rendered_message(fragments: Vec<RenderedFragment>) -> Option<ResponseItem> {
|
||||
let role = fragments.first()?.role();
|
||||
debug_assert!(fragments.iter().all(|fragment| fragment.role() == role));
|
||||
let (content, content_item_kinds): (Vec<_>, Vec<_>) = fragments
|
||||
let content = fragments
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.into_parts().1.into_parts())
|
||||
.unzip();
|
||||
.map(|fragment| fragment.into_parts().1)
|
||||
.collect();
|
||||
|
||||
Some(ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
content,
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_kinds: Some(content_item_kinds),
|
||||
..Default::default()
|
||||
}),
|
||||
})
|
||||
Some(message_from_parts(role, content))
|
||||
}
|
||||
|
||||
pub(crate) fn merge_world_state_updates(updates: Vec<WorldStateUpdate>) -> Vec<ResponseItem> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -44,6 +45,7 @@ fn fragment(role: &'static str, text: &'static str) -> TestFragment {
|
||||
}
|
||||
|
||||
fn message(role: &str, texts: &[&str]) -> ResponseItem {
|
||||
let provenance = serde_json::from_value(json!({"harness_injected": true})).unwrap();
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
@@ -55,6 +57,7 @@ fn message(role: &str, texts: &[&str]) -> ResponseItem {
|
||||
.collect(),
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_metadata: Some(vec![provenance; texts.len()]),
|
||||
content_item_kinds: Some(
|
||||
texts
|
||||
.iter()
|
||||
|
||||
@@ -429,6 +429,7 @@ fn parses_hook_prompt_message_as_distinct_turn_item() {
|
||||
HookPromptFragment {
|
||||
text: "Retry with exactly the phrase meow meow meow.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
metadata: Default::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -464,6 +465,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() {
|
||||
vec![HookPromptFragment {
|
||||
text: "Retry with care & joy.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
metadata: Default::default(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -138,6 +140,7 @@ async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() {
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"images.preparation_error".to_string()
|
||||
)]),
|
||||
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
@@ -413,6 +416,8 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() {
|
||||
ContentItemKind("images.preparation_error".to_string()),
|
||||
ContentItemKind("user.image".to_string()),
|
||||
]),
|
||||
content_item_metadata: from_value(json!([{}, {}, {"harness_injected": true}, {}]))
|
||||
.unwrap(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
@@ -467,6 +472,7 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() {
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"images.resize_notice".to_string()
|
||||
)]),
|
||||
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
@@ -520,6 +526,7 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() {
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"images.resize_notice".to_string()
|
||||
)]),
|
||||
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -130,6 +130,8 @@ use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::BaseInstructionsProvenance;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
use codex_protocol::models::ImageReference;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -3514,6 +3516,7 @@ impl Session {
|
||||
*internal_chat_message_metadata_passthrough =
|
||||
Some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_kinds: Some(content_item_kinds),
|
||||
content_item_metadata: Some(vec![ContentItemMetadata::user(); content.len()]),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -3949,7 +3952,31 @@ impl Session {
|
||||
model_info: &ModelInfo,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
let response_item = communication.to_model_input_item();
|
||||
let mut response_item = communication.to_model_input_item();
|
||||
if let ResponseItem::AgentMessage {
|
||||
content,
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} = &mut response_item
|
||||
{
|
||||
internal_chat_message_metadata_passthrough
|
||||
.get_or_insert_default()
|
||||
.content_item_metadata
|
||||
.get_or_insert_with(|| {
|
||||
vec![
|
||||
ContentItemMetadata::tool(Some(
|
||||
turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.tool_namespace
|
||||
.clone()
|
||||
.map(ContentItemNamespace::from)
|
||||
.unwrap_or(ContentItemNamespace::Functions),
|
||||
));
|
||||
content.len()
|
||||
]
|
||||
});
|
||||
}
|
||||
let (items, _) = self
|
||||
.prepare_conversation_items_for_history(
|
||||
turn_context,
|
||||
@@ -4275,7 +4302,7 @@ impl Session {
|
||||
let turn_context = step_context.turn.as_ref();
|
||||
let mut developer_sections = Vec::<RenderedFragment>::with_capacity(8);
|
||||
let mut contextual_user_sections = Vec::<RenderedFragment>::with_capacity(2);
|
||||
let mut separate_developer_sections = Vec::<RenderedFragment>::new();
|
||||
let mut separate_developer_messages = Vec::<ResponseItem>::new();
|
||||
let mut context_window_hints = Vec::new();
|
||||
let (session_source, auto_compact_window_ids) = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -4332,7 +4359,7 @@ impl Session {
|
||||
{
|
||||
match fragment.slot() {
|
||||
PromptSlot::ContextWindow => {
|
||||
context_window_hints.push(fragment.text().to_string());
|
||||
context_window_hints.push(fragment.into_content());
|
||||
}
|
||||
PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => {
|
||||
developer_sections.push(fragment.into());
|
||||
@@ -4370,7 +4397,7 @@ impl Session {
|
||||
.token_budget
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.use_history_notes_extension)
|
||||
&& let Some(mcp_result) = self
|
||||
&& let Some((text, source_tool_namespace)) = self
|
||||
.services
|
||||
.mcp_runtime
|
||||
.latest_call_tool(
|
||||
@@ -4386,8 +4413,9 @@ impl Session {
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|result| {
|
||||
let text = result
|
||||
.and_then(|call| {
|
||||
let text = call
|
||||
.result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| {
|
||||
@@ -4396,12 +4424,16 @@ impl Session {
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!text.is_empty()).then_some(text)
|
||||
(!text.is_empty()).then_some((text, call.source_tool_namespace))
|
||||
})
|
||||
{
|
||||
context_window_hints.push(mcp_result);
|
||||
context_window_hints.push(codex_context_fragments::AnnotatedContent::text(
|
||||
text,
|
||||
ContentItemKind("notes.thread_hint".to_string()),
|
||||
ContentItemMetadata::tool(source_tool_namespace.map(Into::into)),
|
||||
));
|
||||
}
|
||||
separate_developer_sections.push(
|
||||
separate_developer_messages.push(
|
||||
crate::context::TokenBudgetContext::new(
|
||||
session_source
|
||||
.get_agent_path()
|
||||
@@ -4409,9 +4441,8 @@ impl Session {
|
||||
auto_compact_window_ids.first_window_id,
|
||||
auto_compact_window_ids.previous_window_id,
|
||||
auto_compact_window_ids.window_id,
|
||||
(!context_window_hints.is_empty()).then(|| context_window_hints.join("\n")),
|
||||
)
|
||||
.render_fragment(),
|
||||
.render_with_hints(context_window_hints),
|
||||
);
|
||||
}
|
||||
let (snapshot, updates) = world_state.render_full();
|
||||
@@ -4451,13 +4482,13 @@ impl Session {
|
||||
"developer"
|
||||
if fragment.markers().0 == MultiAgentRoleInstructions::type_markers().0 =>
|
||||
{
|
||||
separate_developer_sections.push(fragment.render_fragment());
|
||||
separate_developer_messages.push(fragment.render_fragment().into());
|
||||
}
|
||||
"developer"
|
||||
if update.placement == Placement::Standalone
|
||||
&& fragment.markers().0.is_empty() =>
|
||||
{
|
||||
separate_developer_sections.push(fragment.render_fragment());
|
||||
separate_developer_messages.push(fragment.render_fragment().into());
|
||||
}
|
||||
"developer" => developer_sections.push(fragment.render_fragment()),
|
||||
"user" => contextual_user_sections.push(fragment.render_fragment()),
|
||||
@@ -4478,13 +4509,7 @@ impl Session {
|
||||
) {
|
||||
items.push(developer_message);
|
||||
}
|
||||
for section in separate_developer_sections.drain(..) {
|
||||
if let Some(developer_message) =
|
||||
crate::context_manager::updates::build_rendered_message(vec![section])
|
||||
{
|
||||
items.push(developer_message);
|
||||
}
|
||||
}
|
||||
items.append(&mut separate_developer_messages);
|
||||
if let Some(initial_multi_agent_mode) = initial_multi_agent_mode
|
||||
&& let Some(message) =
|
||||
crate::context_manager::updates::build_rendered_message(vec![
|
||||
|
||||
@@ -161,6 +161,7 @@ use codex_protocol::mcp::McpAttributionStatus;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -2768,6 +2769,11 @@ async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() {
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let mut expected_item = communication.to_model_input_item();
|
||||
let mut expected = serde_json::to_value(&expected_item).unwrap();
|
||||
expected["internal_chat_message_metadata_passthrough"] = json!({
|
||||
"content_item_metadata": [{"harness_injected": true, "source_tool_namespace": "collaboration"}],
|
||||
});
|
||||
expected_item = serde_json::from_value(expected).unwrap();
|
||||
expected_item.set_turn_id_if_missing(&turn_context.sub_id);
|
||||
|
||||
session
|
||||
@@ -3155,6 +3161,11 @@ async fn prepares_resumed_history_before_installing_it() {
|
||||
ContentItemKind("images.preparation_error".to_string()),
|
||||
ContentItemKind("unknown".to_string()),
|
||||
]),
|
||||
content_item_metadata: Some(vec![
|
||||
ContentItemMetadata::harness(),
|
||||
ContentItemMetadata::harness(),
|
||||
ContentItemMetadata::default(),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
@@ -4123,6 +4134,9 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"generic.developer_instructions".to_string(),
|
||||
)]),
|
||||
content_item_metadata: Some(vec![ContentItemMetadata::developer_instructions(
|
||||
/*from_additional_requirements*/ false,
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ use codex_prompts::ResolvedModelMessages;
|
||||
use codex_prompts::render_model_instructions;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::BaseInstructionsProvenance;
|
||||
use codex_protocol::models::ContentItemNamespace;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_tools::ToolName;
|
||||
|
||||
@@ -173,7 +174,6 @@ impl Session {
|
||||
window_ids.first_window_id,
|
||||
window_ids.previous_window_id,
|
||||
window_ids.window_id,
|
||||
/*thread_hint*/ None,
|
||||
));
|
||||
}
|
||||
let guidance = step_context
|
||||
@@ -354,11 +354,20 @@ impl Session {
|
||||
world_state.add_extension_section(section);
|
||||
}
|
||||
}
|
||||
let v2_namespace = turn_context
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.tool_namespace
|
||||
.clone()
|
||||
.map(ContentItemNamespace::from)
|
||||
.unwrap_or(ContentItemNamespace::Functions);
|
||||
let mut multi_agent_mode = MultiAgentModeState::new(
|
||||
super::multi_agents::effective_multi_agent_mode(step_context),
|
||||
);
|
||||
)
|
||||
.with_namespace(v2_namespace.clone());
|
||||
if let Some(usage_hint_text) = super::multi_agents::usage_hint_text(step_context) {
|
||||
let usage_hint = MultiAgentUsageHintState::new(usage_hint_text);
|
||||
let usage_hint =
|
||||
MultiAgentUsageHintState::new(usage_hint_text).with_namespace(v2_namespace.clone());
|
||||
multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint);
|
||||
world_state.add_section(usage_hint);
|
||||
}
|
||||
@@ -395,7 +404,12 @@ impl Session {
|
||||
)
|
||||
} else {
|
||||
ModelCatalogState::default()
|
||||
},
|
||||
}
|
||||
.with_namespace(match turn_context.multi_agent_version {
|
||||
MultiAgentVersion::Disabled => None,
|
||||
MultiAgentVersion::V1 => Some(ContentItemNamespace::MultiAgentV1),
|
||||
MultiAgentVersion::V2 => Some(v2_namespace),
|
||||
}),
|
||||
);
|
||||
if !crate::guardian::is_basic_session_source(&turn_context.session_source) {
|
||||
world_state.add_section(ManagedDeveloperInstructionsState::new(
|
||||
|
||||
@@ -27,6 +27,7 @@ use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID;
|
||||
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -2939,6 +2940,7 @@ fn multi_agent_v2_interrupted_marker_uses_developer_input_message() {
|
||||
content_item_kinds: Some(vec![ContentItemKind(
|
||||
"generic.turn_aborted".to_string()
|
||||
)]),
|
||||
content_item_metadata: Some(vec![ContentItemMetadata::harness()]),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -5787,6 +5787,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_
|
||||
)?;
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_model_info_override("gpt-5.5", |model| model.use_responses_lite = true)
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -5846,6 +5847,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_
|
||||
let resumed_cwd = test.config.cwd.clone();
|
||||
let mut resume_builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_model_info_override("gpt-5.5", |model| model.use_responses_lite = true)
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| config.cwd = resumed_cwd);
|
||||
let resumed = resume_builder
|
||||
@@ -5861,7 +5863,7 @@ async fn remote_v2_compaction_refreshes_instructions_and_preserves_them_on_cold_
|
||||
assert_single_instruction_fragment(&requests[3], &new_fragment);
|
||||
let resumed_input = requests[3].input();
|
||||
assert_eq!(
|
||||
resumed_input.get(1..=replacement_history.len()),
|
||||
resumed_input.get(2..=replacement_history.len() + 1),
|
||||
Some(replacement_history.as_slice()),
|
||||
"remote-v2 cold resume should replay persisted replacement history verbatim"
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_auth_manager_token(
|
||||
/*wait_for_server*/ true,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(tool_result.is_error, Some(false));
|
||||
assert_eq!(tool_result.result.is_error, Some(false));
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
|
||||
@@ -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,10 @@
|
||||
// All this file should be replaced by the existing fragment implementation ofc
|
||||
//! Extension prompt placement with content and producer attribution kept together.
|
||||
|
||||
use codex_context_fragments::AnnotatedContent;
|
||||
use codex_context_fragments::RenderedFragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PromptSlot {
|
||||
@@ -12,20 +14,35 @@ pub enum PromptSlot {
|
||||
ContextWindow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PromptFragment {
|
||||
slot: PromptSlot,
|
||||
text: String,
|
||||
content_kind: ContentItemKind,
|
||||
content: AnnotatedContent,
|
||||
}
|
||||
|
||||
impl PromptFragment {
|
||||
/// Creates a prompt fragment for the given slot.
|
||||
/// Creates harness-provided context for the given slot.
|
||||
pub fn new(slot: PromptSlot, text: impl Into<String>, content_kind: ContentItemKind) -> Self {
|
||||
Self {
|
||||
slot,
|
||||
text: text.into(),
|
||||
content_kind,
|
||||
content: AnnotatedContent::text(text, content_kind, ContentItemMetadata::harness()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates tool-supplied context without changing its prompt placement.
|
||||
pub fn tool(
|
||||
slot: PromptSlot,
|
||||
text: impl Into<String>,
|
||||
content_kind: ContentItemKind,
|
||||
namespace: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
slot,
|
||||
content: AnnotatedContent::text(
|
||||
text,
|
||||
content_kind,
|
||||
ContentItemMetadata::tool(Some(namespace.into())),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,20 +63,25 @@ impl PromptFragment {
|
||||
|
||||
/// Returns the model-visible text.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
let ContentItem::InputText { text } = self.content.content() else {
|
||||
unreachable!("prompt fragments contain text");
|
||||
};
|
||||
text
|
||||
}
|
||||
|
||||
/// Returns the producer-owned classification of the model-visible text.
|
||||
pub fn content_kind(&self) -> &ContentItemKind {
|
||||
&self.content_kind
|
||||
self.content.kind()
|
||||
}
|
||||
|
||||
/// Consumes the placement wrapper when assembling a shared context message.
|
||||
pub fn into_content(self) -> AnnotatedContent {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PromptFragment> for RenderedFragment {
|
||||
fn from(fragment: PromptFragment) -> Self {
|
||||
Self::new(
|
||||
"developer",
|
||||
AnnotatedContent::input_text(fragment.text, fragment.content_kind),
|
||||
)
|
||||
Self::new("developer", fragment.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use codex_exec_server_protocol::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use serde_json::Value;
|
||||
@@ -47,6 +48,7 @@ pub struct RenderedWorldStateFragment {
|
||||
role: &'static str,
|
||||
markers: (&'static str, &'static str),
|
||||
body: String,
|
||||
metadata: ContentItemMetadata,
|
||||
}
|
||||
|
||||
impl RenderedWorldStateFragment {
|
||||
@@ -59,9 +61,20 @@ impl RenderedWorldStateFragment {
|
||||
role,
|
||||
markers,
|
||||
body: body.into(),
|
||||
metadata: ContentItemMetadata::harness(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the producer's source before the harness assembles the context message.
|
||||
pub fn with_metadata(mut self, metadata: ContentItemMetadata) -> Self {
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &ContentItemMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
pub fn role(&self) -> &'static str {
|
||||
self.role
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn rendered_classifier_text(config: &GuardianV2Config, policy: &str) -> String {
|
||||
let (_, content) = config
|
||||
.render_classifier_instructions(policy, "")
|
||||
.into_parts();
|
||||
let (ContentItem::InputText { text }, _) = content.into_parts() else {
|
||||
let (ContentItem::InputText { text }, _, _) = content.into_parts() else {
|
||||
panic!("classifier instructions must be text");
|
||||
};
|
||||
text
|
||||
@@ -53,7 +53,7 @@ fn extra_policy_reaches_templated_and_legacy_classifier_instructions() {
|
||||
let (_, content) = config
|
||||
.render_classifier_instructions(policy, extra_policy)
|
||||
.into_parts();
|
||||
let (ContentItem::InputText { text }, _) = content.into_parts() else {
|
||||
let (ContentItem::InputText { text }, _, _) = content.into_parts() else {
|
||||
panic!("classifier instructions must be text");
|
||||
};
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_context_fragments::ContextualUserFragment;
|
||||
use codex_guardian_context::TrustedTool;
|
||||
use codex_http_client::HttpClientBuilder;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -17,7 +18,11 @@ use wiremock::matchers::path;
|
||||
fn adapter_rejects_unsupported_evidence() {
|
||||
let rubric = RenderedFragment::new(
|
||||
"developer",
|
||||
AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())),
|
||||
AnnotatedContent::text(
|
||||
"Classify risk.",
|
||||
ContentItemKind("guardian.test".into()),
|
||||
ContentItemMetadata::harness(),
|
||||
),
|
||||
);
|
||||
let user: ResponseItem = serde_json::from_value(json!({"type":"message", "role":"user", "content":[
|
||||
{"type":"input_text", "text":"Authorized task"}, {"type":"input_text", "text":"Planned action"}
|
||||
@@ -53,7 +58,11 @@ fn adapter_rejects_unsupported_evidence() {
|
||||
fn adapter_preserves_trusted_tool_authority_and_scope() {
|
||||
let rubric = RenderedFragment::new(
|
||||
"developer",
|
||||
AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())),
|
||||
AnnotatedContent::text(
|
||||
"Classify risk.",
|
||||
ContentItemKind("guardian.test".into()),
|
||||
ContentItemMetadata::harness(),
|
||||
),
|
||||
);
|
||||
let tool = TrustedTool {
|
||||
server: "example".into(),
|
||||
@@ -123,7 +132,11 @@ async fn http_contract_and_untrusted_response_validation() {
|
||||
let mut request = super::super::sampler::tests::sample_request("turn");
|
||||
request.instructions = RenderedFragment::new(
|
||||
"developer",
|
||||
AnnotatedContent::input_text("Classify risk", ContentItemKind("guardian.test".into())),
|
||||
AnnotatedContent::text(
|
||||
"Classify risk",
|
||||
ContentItemKind("guardian.test".into()),
|
||||
ContentItemMetadata::harness(),
|
||||
),
|
||||
);
|
||||
let body = json!({"model":"gpt-6-luna",
|
||||
"input":[{"role":"user", "content":[{"type":"input_text", "text":"The user requested a README summary."}]}],
|
||||
|
||||
@@ -1379,6 +1379,9 @@ classifier_instructions = "Predict future violations.\n# Security Policy\n{{ ten
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -1421,6 +1424,9 @@ max_classifier_instruction_tokens = 256
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -1505,6 +1511,9 @@ max_recent_non_user_entries = 8
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -1937,6 +1946,9 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -2118,6 +2130,9 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -2813,6 +2828,9 @@ async fn contributor_uses_catalog_policy_without_a_configured_override() -> Resu
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
@@ -2859,6 +2877,9 @@ async fn contributor_preserves_uncapped_classifier_instructions() -> Result<()>
|
||||
"role": "developer",
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
|
||||
@@ -258,6 +258,9 @@ fn assert_classifier_instructions(request: &serde_json::Value) {
|
||||
}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["guardian.classifier_instructions"],
|
||||
"content_item_metadata": [{
|
||||
"provenance": {"type": "harness", "hook": false},
|
||||
}],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
)]
|
||||
);
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -2237,7 +2238,7 @@ struct StaticMcpExecutor {
|
||||
}
|
||||
|
||||
impl HookMcpExecutor for StaticMcpExecutor {
|
||||
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
|
||||
fn execute(&self, call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
|
||||
async move {
|
||||
let output = self
|
||||
.outputs_by_tool
|
||||
@@ -2245,7 +2246,10 @@ impl HookMcpExecutor for StaticMcpExecutor {
|
||||
.unwrap_or(&self.output)
|
||||
.clone();
|
||||
self.calls.lock().expect("lock MCP calls").push(call);
|
||||
Ok(output)
|
||||
Ok(HookMcpOutput {
|
||||
text: output,
|
||||
source_tool_namespace: None,
|
||||
})
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
@@ -2650,11 +2654,14 @@ async fn executor_stop_hooks_do_not_delay_stop_completion() {
|
||||
}
|
||||
|
||||
impl HookMcpExecutor for BlockingMcpExecutor {
|
||||
fn execute(&self, _call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<String>> {
|
||||
fn execute(&self, _call: HookMcpCall) -> BoxFuture<'_, anyhow::Result<HookMcpOutput>> {
|
||||
async move {
|
||||
self.started.notify_one();
|
||||
self.release.notified().await;
|
||||
Ok(String::new())
|
||||
Ok(HookMcpOutput {
|
||||
text: String::new(),
|
||||
source_tool_namespace: None,
|
||||
})
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -691,7 +699,13 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<Res
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
phase: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
internal_chat_message_metadata_passthrough: metadata
|
||||
.iter()
|
||||
.any(|part| part != &ContentItemMetadata::default())
|
||||
.then_some(InternalChatMessageMetadataPassthrough {
|
||||
content_item_metadata: Some(metadata),
|
||||
..Default::default()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -723,7 +737,7 @@ pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(HookPromptFragment { text, hook_run_id })
|
||||
Some(HookPromptFragment::from_single_hook(text, hook_run_id))
|
||||
}
|
||||
|
||||
fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {
|
||||
@@ -863,6 +877,7 @@ mod tests {
|
||||
HookPromptFragment {
|
||||
text: "Retry with tests.".to_string(),
|
||||
hook_run_id: "hook-run-1".to_string(),
|
||||
metadata: Default::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ pub use executed_tool_calls::bound_executed_tool_calls_for_message;
|
||||
pub use executed_tool_calls::executed_tool_call_metadata_bytes;
|
||||
pub use executed_tool_calls::normalize_executed_tool_call_arguments;
|
||||
pub use item_metadata::ContentItemKind;
|
||||
pub use item_metadata::ContentItemMetadata;
|
||||
pub use item_metadata::ContentItemNamespace;
|
||||
pub use item_metadata::ContentItemProvenance;
|
||||
|
||||
/// Controls the per-command sandbox override requested by a shell-like tool call.
|
||||
#[derive(
|
||||
@@ -974,6 +977,12 @@ pub struct InternalChatMessageMetadataPassthrough {
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
pub content_item_kinds: Option<Vec<ContentItemKind>>,
|
||||
/// Positional source attribution for `content`; missing historical entries are Unknown.
|
||||
#[serde_as(deserialize_as = "serde_with::DefaultOnError")]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
pub content_item_metadata: Option<Vec<ContentItemMetadata>>,
|
||||
// Ignore input values so requests and rollout reloads cannot fake tool call records.
|
||||
// A resumed thread can record new calls, but cannot prove old calls from this payload.
|
||||
/// Host-owned Code Mode cell shared by its `exec` and subsequent `wait` outputs.
|
||||
@@ -1409,7 +1418,7 @@ impl ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes content item classifications while preserving other passthrough metadata.
|
||||
/// Removes content item classifications and attribution, preserving other metadata.
|
||||
pub fn clear_content_item_kinds(&mut self) {
|
||||
let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else {
|
||||
return;
|
||||
@@ -1419,11 +1428,24 @@ impl ResponseItem {
|
||||
};
|
||||
|
||||
metadata_value.content_item_kinds = None;
|
||||
metadata_value.content_item_metadata = None;
|
||||
if metadata_value == &InternalChatMessageMetadataPassthrough::default() {
|
||||
*metadata = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes per-content attribution for endpoints that do not yet accept it.
|
||||
pub fn clear_content_item_metadata(&mut self) {
|
||||
if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut()
|
||||
&& let Some(value) = metadata
|
||||
{
|
||||
value.content_item_metadata = None;
|
||||
if value == &InternalChatMessageMetadataPassthrough::default() {
|
||||
*metadata = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_chat_message_metadata_passthrough(
|
||||
&self,
|
||||
) -> Option<&InternalChatMessageMetadataPassthrough> {
|
||||
|
||||
@@ -38,6 +38,7 @@ fn executed_tool_call_metadata_field_bytes(
|
||||
} else if metadata.turn_id.is_some()
|
||||
|| metadata.create_time.is_some()
|
||||
|| metadata.content_item_kinds.is_some()
|
||||
|| metadata.content_item_metadata.is_some()
|
||||
{
|
||||
bytes + 1
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Per-part context sources, independent of message role, visibility, and trust.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -12,6 +14,151 @@ impl ContentItemKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A callable namespace. Built-ins are typed; configured and MCP namespaces stay open.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum ContentItemNamespace {
|
||||
Clock,
|
||||
Functions,
|
||||
ToolSearch,
|
||||
Collaboration,
|
||||
MultiAgentV1,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<String> for ContentItemNamespace {
|
||||
fn from(namespace: String) -> Self {
|
||||
match namespace.as_str() {
|
||||
"clock" => Self::Clock,
|
||||
"functions" => Self::Functions,
|
||||
"tool_search" => Self::ToolSearch,
|
||||
"collaboration" => Self::Collaboration,
|
||||
"multi_agent_v1" => Self::MultiAgentV1,
|
||||
_ => Self::Other(namespace),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ContentItemNamespace> for String {
|
||||
fn from(namespace: ContentItemNamespace) -> Self {
|
||||
match namespace {
|
||||
ContentItemNamespace::Clock => "clock".to_string(),
|
||||
ContentItemNamespace::Functions => "functions".to_string(),
|
||||
ContentItemNamespace::ToolSearch => "tool_search".to_string(),
|
||||
ContentItemNamespace::Collaboration => "collaboration".to_string(),
|
||||
ContentItemNamespace::MultiAgentV1 => "multi_agent_v1".to_string(),
|
||||
ContentItemNamespace::Other(namespace) => namespace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who supplied a part. Tool-related context remains Tool even when the harness writes it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentItemProvenance {
|
||||
/// History saved before attribution, or content whose source is not known.
|
||||
#[default]
|
||||
#[serde(alias = "user_configured")]
|
||||
Unknown,
|
||||
/// The regular developer-instruction slot or additional requirements-layer instructions.
|
||||
DeveloperInstructions { from_additional_requirements: bool },
|
||||
/// Skill catalogs, usage guidance, and selected skill contents.
|
||||
Skills,
|
||||
/// Instructions loaded from AGENTS.md.
|
||||
AgentsMd,
|
||||
/// Harness-managed context; command-hook output is distinguished from built-in context.
|
||||
Harness {
|
||||
#[serde(default)]
|
||||
hook: bool,
|
||||
},
|
||||
/// A tool result or tool-specific context; an unlisted tool can have no known namespace.
|
||||
Tool {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
namespace: Option<ContentItemNamespace>,
|
||||
},
|
||||
/// Ordinary user input, not an injected instruction fragment with a user role.
|
||||
User,
|
||||
}
|
||||
|
||||
/// Attribution for one position in a harness-emitted item's content array.
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct ContentItemMetadata {
|
||||
pub provenance: ContentItemProvenance,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentItemMetadata {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Older records may lack attribution. Neither the old user_configured category
|
||||
// nor a bare harness_injected:false identifies a source precisely enough.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredMetadata {
|
||||
#[serde(default)]
|
||||
provenance: Option<ContentItemProvenance>,
|
||||
#[serde(default)]
|
||||
harness_injected: Option<bool>,
|
||||
#[serde(default)]
|
||||
source_tool_namespace: Option<String>,
|
||||
}
|
||||
let stored = StoredMetadata::deserialize(deserializer)?;
|
||||
let provenance = stored.provenance.unwrap_or_else(|| {
|
||||
match (stored.source_tool_namespace, stored.harness_injected) {
|
||||
(Some(namespace), _) => ContentItemProvenance::Tool {
|
||||
namespace: Some(namespace.into()),
|
||||
},
|
||||
(None, Some(true)) => ContentItemProvenance::Harness { hook: false },
|
||||
(None, Some(false) | None) => ContentItemProvenance::Unknown,
|
||||
}
|
||||
});
|
||||
Ok(Self { provenance })
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentItemMetadata {
|
||||
pub fn harness() -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::Harness { hook: false },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_hook() -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::Harness { hook: true },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn developer_instructions(from_additional_requirements: bool) -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::DeveloperInstructions {
|
||||
from_additional_requirements,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skills() -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::Skills,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agents_md() -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::AgentsMd,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user() -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::User,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool(namespace: Option<ContentItemNamespace>) -> Self {
|
||||
Self {
|
||||
provenance: ContentItemProvenance::Tool { namespace },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "item_metadata_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user