mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Include tool namespace inventory in turn metadata (#37492)
## What changed - Add opt-in `tool_namespaces_info` metadata for Responses Lite turns when `tool_registry.turn_metadata_includes_tool_info` is enabled. - Describe each model-visible function's namespace, direct and deferred exposure, Code Mode name, and harness or MCP ownership. - Reject visible namespaces shared by different owners when strict collision checking and tool inventory metadata are enabled. - Keep tool inventories out of compatibility headers and metadata sent to MCP servers. ## Testing - Cover opt-in behavior, MCP exposure details, namespace ownership collisions, reserved metadata handling, and Responses Lite request serialization. GitOrigin-RevId: 6a6965d2ba98e29444fdfbb4ccec6aafcf0391e6
This commit is contained in:
@@ -32,6 +32,7 @@ pub(crate) const WINDOW_ID_KEY: &str = "window_id";
|
||||
pub(crate) const REQUEST_KIND_KEY: &str = "request_kind";
|
||||
pub(crate) const COMPACTION_KEY: &str = "compaction";
|
||||
pub(crate) const CODE_MODE_TOOL_NAMES_KEY: &str = "code_mode_tool_names";
|
||||
pub(crate) const TOOL_NAMESPACES_INFO_KEY: &str = "tool_namespaces_info";
|
||||
pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms";
|
||||
|
||||
pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id";
|
||||
@@ -58,6 +59,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[
|
||||
REQUEST_KIND_KEY,
|
||||
COMPACTION_KEY,
|
||||
CODE_MODE_TOOL_NAMES_KEY,
|
||||
TOOL_NAMESPACES_INFO_KEY,
|
||||
TURN_STARTED_AT_UNIX_MS_KEY,
|
||||
FORKED_FROM_THREAD_ID_KEY,
|
||||
PARENT_THREAD_ID_KEY,
|
||||
@@ -149,6 +151,34 @@ pub(crate) struct TurnMetadataWorkspace {
|
||||
pub(crate) has_changes: Option<bool>,
|
||||
}
|
||||
|
||||
/// Model-visible namespaces indexed by their effective Responses Lite names.
|
||||
pub(crate) type TurnToolNamespacesInfo = BTreeMap<String, TurnToolNamespaceInfo>;
|
||||
|
||||
/// The model-visible functions belonging to one effective namespace.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub(crate) struct TurnToolNamespaceInfo {
|
||||
pub(crate) name: String,
|
||||
pub(crate) functions: BTreeMap<String, TurnToolFunctionInfo>,
|
||||
}
|
||||
|
||||
/// The effective per-turn exposure of one model-visible function.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub(crate) struct TurnToolFunctionInfo {
|
||||
pub(crate) name: String,
|
||||
pub(crate) direct: bool,
|
||||
pub(crate) code_mode_name: Option<String>,
|
||||
pub(crate) deferred: bool,
|
||||
pub(crate) source: TurnToolSource,
|
||||
}
|
||||
|
||||
/// The owner responsible for dispatching one effective tool function.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub(crate) enum TurnToolSource {
|
||||
Harness,
|
||||
Mcp { server_name: String },
|
||||
}
|
||||
|
||||
/// Caller-owned snapshot of Codex metadata sent to ResponsesAPI.
|
||||
///
|
||||
/// The full Codex turn metadata blob is transported canonically as
|
||||
@@ -173,6 +203,7 @@ pub struct CodexResponsesMetadata {
|
||||
pub(crate) sandbox: Option<String>,
|
||||
pub(crate) workspaces: BTreeMap<String, TurnMetadataWorkspace>,
|
||||
pub(crate) code_mode_tool_names: Option<BTreeMap<String, ToolName>>,
|
||||
pub(crate) tool_namespaces_info: Option<TurnToolNamespacesInfo>,
|
||||
pub(crate) turn_started_at_unix_ms: Option<i64>,
|
||||
pub(crate) extra: BTreeMap<String, String>,
|
||||
}
|
||||
@@ -201,6 +232,7 @@ impl CodexResponsesMetadata {
|
||||
sandbox: None,
|
||||
workspaces: BTreeMap::new(),
|
||||
code_mode_tool_names: None,
|
||||
tool_namespaces_info: None,
|
||||
turn_started_at_unix_ms: None,
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
@@ -257,11 +289,12 @@ impl CodexResponsesMetadata {
|
||||
pub(crate) fn compatibility_headers(&self) -> ApiHeaderMap {
|
||||
let mut headers = ApiHeaderMap::new();
|
||||
insert_header(&mut headers, X_CODEX_WINDOW_ID_HEADER, &self.window_id);
|
||||
// Direct x-codex-turn-metadata is compatibility output. Keep the unbounded Code Mode
|
||||
// mapping in client_metadata only so HTTP and WebSocket headers remain bounded.
|
||||
// Direct x-codex-turn-metadata is compatibility output. Keep unbounded tool inventories
|
||||
// in client_metadata only so HTTP and WebSocket compatibility headers remain bounded.
|
||||
if self.has_turn_metadata()
|
||||
&& let Ok(turn_metadata_json) = to_ascii_json_string(&CodexTurnMetadataPayload {
|
||||
code_mode_tool_names: None,
|
||||
tool_namespaces_info: None,
|
||||
..self.turn_metadata_payload()
|
||||
})
|
||||
{
|
||||
@@ -311,6 +344,7 @@ impl CodexResponsesMetadata {
|
||||
sandbox: self.sandbox.as_deref(),
|
||||
workspaces: non_empty_workspaces(&self.workspaces),
|
||||
code_mode_tool_names: self.code_mode_tool_names.as_ref(),
|
||||
tool_namespaces_info: self.tool_namespaces_info.as_ref(),
|
||||
turn_started_at_unix_ms: self.turn_started_at_unix_ms,
|
||||
compaction,
|
||||
// responsesapi_client_metadata enriches the Codex turn metadata blob, not literal
|
||||
@@ -405,6 +439,8 @@ struct CodexTurnMetadataPayload<'a> {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
code_mode_tool_names: Option<&'a BTreeMap<String, ToolName>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tool_namespaces_info: Option<&'a TurnToolNamespacesInfo>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
turn_started_at_unix_ms: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
compaction: Option<CompactionTurnMetadata>,
|
||||
|
||||
@@ -232,6 +232,10 @@ impl CoreToolRuntime for McpHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
fn mcp_server_name(&self) -> Option<&str> {
|
||||
Some(&self.tool_info.server_name)
|
||||
}
|
||||
|
||||
fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags {
|
||||
let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())];
|
||||
if let Some(origin) = &self.tool_info.server_origin {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) mod runtimes;
|
||||
pub(crate) mod sandboxing;
|
||||
pub(crate) mod spec_plan;
|
||||
pub(crate) mod tool_dispatch_trace;
|
||||
mod tool_namespaces_info;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
|
||||
@@ -66,6 +66,11 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the owning server only for MCP-backed tool runtimes.
|
||||
fn mcp_server_name(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(
|
||||
payload,
|
||||
|
||||
@@ -59,9 +59,11 @@ use crate::tools::registry::RegisteredTool;
|
||||
use crate::tools::registry::ToolExposure;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use crate::tools::tool_namespaces_info::collect_tool_namespaces_info;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
@@ -370,6 +372,11 @@ pub(crate) fn finalize_tool_router(
|
||||
}
|
||||
|
||||
let code_mode_tool_names = register_code_mode_executors(turn_context, &mut registry);
|
||||
let include_tool_namespaces_info = turn_context
|
||||
.config
|
||||
.tool_registry
|
||||
.turn_metadata_includes_tool_info
|
||||
&& turn_context.model_info.use_responses_lite;
|
||||
|
||||
if turn_context.config.tool_registry.error_on_tool_collisions {
|
||||
if let Some(tool_name) = registry.first_collision() {
|
||||
@@ -379,6 +386,7 @@ pub(crate) fn finalize_tool_router(
|
||||
}
|
||||
|
||||
let mut namespace_descriptions = BTreeMap::new();
|
||||
let mut namespace_owners = BTreeMap::new();
|
||||
for tool in registry.entries() {
|
||||
let owned_spec;
|
||||
let spec = if let Some(spec) = tool.runtime.immutable_spec() {
|
||||
@@ -387,6 +395,27 @@ pub(crate) fn finalize_tool_router(
|
||||
owned_spec = tool.runtime.spec();
|
||||
&owned_spec
|
||||
};
|
||||
if include_tool_namespaces_info && tool.exposure != ToolExposure::Hidden {
|
||||
let namespace_name = match spec {
|
||||
ToolSpec::Namespace(namespace) => namespace.name.as_str(),
|
||||
ToolSpec::Function(_) | ToolSpec::Freeform(_) => DEFAULT_FUNCTION_NAMESPACE,
|
||||
ToolSpec::ToolSearch { .. } => TOOL_SEARCH_TOOL_NAME,
|
||||
ToolSpec::WebSearch { .. } => continue,
|
||||
};
|
||||
let owner = tool.runtime.mcp_server_name();
|
||||
match namespace_owners.get(namespace_name) {
|
||||
Some(existing_owner) if existing_owner != &owner => {
|
||||
return Err(
|
||||
CodexErrorDetails::ToolCollision(namespace_name.to_string()).into()
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
namespace_owners.insert(namespace_name.to_string(), owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ToolSpec::Namespace(namespace) = spec else {
|
||||
continue;
|
||||
};
|
||||
@@ -407,6 +436,16 @@ pub(crate) fn finalize_tool_router(
|
||||
|
||||
let model_visible_specs =
|
||||
build_model_visible_specs(turn_context, ®istry, &code_mode_tool_names, hosted_specs);
|
||||
if include_tool_namespaces_info {
|
||||
turn_context
|
||||
.turn_metadata_state
|
||||
.set_tool_namespaces_info(collect_tool_namespaces_info(
|
||||
®istry,
|
||||
&code_mode_tool_names,
|
||||
&model_visible_specs,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ToolRouter::from_parts(registry, model_visible_specs))
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ use crate::WaitForEnvironmentToolConfig;
|
||||
use crate::config::CurrentTimeReminderConfig;
|
||||
use crate::environment_selection::TurnEnvironmentState;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::TurnToolFunctionInfo;
|
||||
use crate::responses_metadata::TurnToolSource;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::session::tests::mcp_config_for_test;
|
||||
@@ -1207,6 +1209,140 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_namespaces_info_is_opt_in_and_tracks_mcp_exposure() {
|
||||
for enabled in [false, true] {
|
||||
let mut metadata_state = None;
|
||||
probe_with(
|
||||
|turn| {
|
||||
update_config(turn, |config| {
|
||||
config.tool_registry.turn_metadata_includes_tool_info = enabled;
|
||||
});
|
||||
set_feature(turn, Feature::CodeMode, /*enabled*/ true);
|
||||
turn.model_info.supports_search_tool = true;
|
||||
turn.model_info.use_responses_lite = true;
|
||||
metadata_state = Some(Arc::clone(&turn.turn_metadata_state));
|
||||
},
|
||||
ToolPlanInputs {
|
||||
tool_runtimes: vec![
|
||||
mcp_runtime("registry", "mcp__registry", "direct", ToolExposure::Direct),
|
||||
mcp_runtime(
|
||||
"registry",
|
||||
"mcp__registry",
|
||||
"deferred",
|
||||
ToolExposure::Deferred,
|
||||
),
|
||||
],
|
||||
..ToolPlanInputs::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let metadata = metadata_state
|
||||
.expect("tool planning should capture the turn metadata")
|
||||
.to_responses_metadata(
|
||||
"installation".to_string(),
|
||||
"window".to_string(),
|
||||
CodexResponsesRequestKind::Turn,
|
||||
);
|
||||
let Some(namespaces) = metadata.tool_namespaces_info else {
|
||||
assert!(!enabled, "opted-in tool planning should publish namespaces");
|
||||
continue;
|
||||
};
|
||||
assert!(enabled, "tool namespaces require opt-in");
|
||||
|
||||
let namespace = namespaces
|
||||
.get("mcp__registry")
|
||||
.expect("MCP namespace should be included");
|
||||
assert_eq!(namespace.name, "mcp__registry");
|
||||
assert_eq!(
|
||||
namespace.functions.get("direct"),
|
||||
Some(&TurnToolFunctionInfo {
|
||||
name: "direct".to_string(),
|
||||
direct: true,
|
||||
code_mode_name: Some("mcp__registry__direct".to_string()),
|
||||
deferred: false,
|
||||
source: TurnToolSource::Mcp {
|
||||
server_name: "registry".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
namespace.functions.get("deferred"),
|
||||
Some(&TurnToolFunctionInfo {
|
||||
name: "deferred".to_string(),
|
||||
direct: false,
|
||||
code_mode_name: Some("mcp__registry__deferred".to_string()),
|
||||
deferred: true,
|
||||
source: TurnToolSource::Mcp {
|
||||
server_name: "registry".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_namespace_ownership_requires_tool_namespace_inventory_opt_in() {
|
||||
for (enabled, second_exposure) in [
|
||||
(false, ToolExposure::Direct),
|
||||
(true, ToolExposure::Direct),
|
||||
(true, ToolExposure::Hidden),
|
||||
] {
|
||||
let (_session, mut turn) = make_session_and_context().await;
|
||||
update_config(&mut turn, |config| {
|
||||
config.tool_registry.error_on_tool_collisions = true;
|
||||
config.tool_registry.turn_metadata_includes_tool_info = enabled;
|
||||
});
|
||||
turn.model_info.use_responses_lite = true;
|
||||
let step_context = StepContext::for_test(Arc::new(turn));
|
||||
let mut registry = build_core_tool_registry(
|
||||
step_context.turn.as_ref(),
|
||||
&step_context.environments,
|
||||
step_context.mcp.as_ref(),
|
||||
/*tool_suggest_candidates*/ None,
|
||||
/*wait_for_environment_tool_config*/ None,
|
||||
);
|
||||
let runtimes = [
|
||||
("first", "lookup", ToolExposure::Direct),
|
||||
("second", "list", second_exposure),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(server_name, tool_name, exposure)| {
|
||||
let mut tool = mcp_tool(server_name, "shared", tool_name);
|
||||
tool.namespace_description = Some("Shared tools.".to_string());
|
||||
RegisteredTool {
|
||||
runtime: Arc::new(McpHandler::new(tool).expect("MCP tool spec should build")),
|
||||
exposure,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hosted_specs = append_source_tools(
|
||||
step_context.turn.as_ref(),
|
||||
&mut registry,
|
||||
runtimes,
|
||||
Vec::new(),
|
||||
&[],
|
||||
);
|
||||
let result = super::finalize_tool_router(
|
||||
step_context.turn.as_ref(),
|
||||
registry,
|
||||
hosted_specs,
|
||||
&Default::default(),
|
||||
);
|
||||
|
||||
if enabled && second_exposure != ToolExposure::Hidden {
|
||||
let error = result.err().expect("mixed namespace ownership should fail");
|
||||
assert!(matches!(
|
||||
error.details(),
|
||||
CodexErrorDetails::ToolCollision(name) if name == "shared"
|
||||
));
|
||||
} else {
|
||||
assert!(result.is_ok(), "existing strict behavior should not change");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() {
|
||||
let plan = probe_with(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::responses_metadata::TurnToolFunctionInfo;
|
||||
use crate::responses_metadata::TurnToolNamespaceInfo;
|
||||
use crate::responses_metadata::TurnToolNamespacesInfo;
|
||||
use crate::responses_metadata::TurnToolSource;
|
||||
use crate::tools::registry::ToolExposure;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
const TOOL_SEARCH_FUNCTION_NAME: &str = "tool_search_tool";
|
||||
|
||||
pub(super) fn collect_tool_namespaces_info(
|
||||
registry: &ToolRegistry,
|
||||
code_mode_tool_names: &BTreeMap<String, ToolName>,
|
||||
model_visible_specs: &[ToolSpec],
|
||||
) -> TurnToolNamespacesInfo {
|
||||
let mut direct_functions = BTreeSet::new();
|
||||
let mut native_tool_search_visible = false;
|
||||
for spec in model_visible_specs {
|
||||
match spec {
|
||||
ToolSpec::Function(_) | ToolSpec::Freeform(_) => {
|
||||
direct_functions.insert((DEFAULT_FUNCTION_NAMESPACE, spec.name()));
|
||||
}
|
||||
ToolSpec::Namespace(namespace) => {
|
||||
for tool in &namespace.tools {
|
||||
let function_name = match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) => tool.name.as_str(),
|
||||
ResponsesApiNamespaceTool::Custom(tool) => tool.name.as_str(),
|
||||
};
|
||||
direct_functions.insert((namespace.name.as_str(), function_name));
|
||||
}
|
||||
}
|
||||
ToolSpec::ToolSearch { .. } => {
|
||||
native_tool_search_visible = true;
|
||||
direct_functions.insert((TOOL_SEARCH_TOOL_NAME, TOOL_SEARCH_FUNCTION_NAME));
|
||||
}
|
||||
ToolSpec::WebSearch { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let code_mode_names_by_tool = code_mode_tool_names
|
||||
.iter()
|
||||
.map(|(code_mode_name, tool_name)| {
|
||||
(tool_name.clone().with_default_namespace(), code_mode_name)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut namespaces = TurnToolNamespacesInfo::default();
|
||||
|
||||
for tool in registry.entries() {
|
||||
let runtime = &tool.runtime;
|
||||
let exposure = tool.exposure;
|
||||
if exposure == ToolExposure::Hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_name = runtime.tool_name().with_default_namespace();
|
||||
let is_tool_search = tool_name.name == TOOL_SEARCH_TOOL_NAME
|
||||
&& matches!(runtime.spec(), ToolSpec::ToolSearch { .. });
|
||||
let (namespace_name, function_name) = if is_tool_search {
|
||||
(TOOL_SEARCH_TOOL_NAME, TOOL_SEARCH_FUNCTION_NAME)
|
||||
} else {
|
||||
(
|
||||
tool_name
|
||||
.namespace
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_FUNCTION_NAMESPACE),
|
||||
tool_name.name.as_str(),
|
||||
)
|
||||
};
|
||||
let direct = direct_functions.contains(&(namespace_name, function_name));
|
||||
let code_mode_name = code_mode_names_by_tool
|
||||
.get(&tool_name)
|
||||
.map(|name| (*name).clone());
|
||||
let deferred = exposure.is_deferred()
|
||||
&& native_tool_search_visible
|
||||
&& (runtime.immutable_spec().is_some() || runtime.search_info().is_some());
|
||||
if !direct && !deferred && code_mode_name.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let namespace = namespaces
|
||||
.entry(namespace_name.to_string())
|
||||
.or_insert_with(|| TurnToolNamespaceInfo {
|
||||
name: namespace_name.to_string(),
|
||||
functions: BTreeMap::new(),
|
||||
});
|
||||
namespace
|
||||
.functions
|
||||
.entry(function_name.to_string())
|
||||
.or_insert_with(|| TurnToolFunctionInfo {
|
||||
name: function_name.to_string(),
|
||||
direct,
|
||||
code_mode_name,
|
||||
deferred,
|
||||
source: match runtime.mcp_server_name() {
|
||||
Some(server_name) => TurnToolSource::Mcp {
|
||||
server_name: server_name.to_string(),
|
||||
},
|
||||
None => TurnToolSource::Harness,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
namespaces
|
||||
}
|
||||
@@ -12,11 +12,11 @@ use std::sync::atomic::Ordering;
|
||||
use serde_json::Value;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::responses_metadata::CODE_MODE_TOOL_NAMES_KEY;
|
||||
use crate::responses_metadata::CodexResponsesMetadata;
|
||||
use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::PARENT_TURN_ID_KEY;
|
||||
use crate::responses_metadata::TurnMetadataWorkspace;
|
||||
use crate::responses_metadata::TurnToolNamespacesInfo;
|
||||
use crate::responses_metadata::filter_extra_metadata;
|
||||
use crate::responses_metadata::subagent_header_value;
|
||||
use crate::responses_metadata::subagent_metadata_kind;
|
||||
@@ -104,6 +104,7 @@ pub(crate) struct TurnMetadataState {
|
||||
sandbox: Option<String>,
|
||||
enriched_workspaces: RwLock<Option<BTreeMap<String, TurnMetadataWorkspace>>>,
|
||||
code_mode_tool_names: RwLock<Option<BTreeMap<String, ToolName>>>,
|
||||
tool_namespaces_info: RwLock<Option<TurnToolNamespacesInfo>>,
|
||||
turn_started_at_unix_ms: RwLock<Option<i64>>,
|
||||
responsesapi_client_metadata: RwLock<BTreeMap<String, String>>,
|
||||
user_input_requested_during_turn: AtomicBool,
|
||||
@@ -149,6 +150,7 @@ impl TurnMetadataState {
|
||||
sandbox,
|
||||
enriched_workspaces: RwLock::new(None),
|
||||
code_mode_tool_names: RwLock::new(None),
|
||||
tool_namespaces_info: RwLock::new(None),
|
||||
turn_started_at_unix_ms: RwLock::new(None),
|
||||
responsesapi_client_metadata: RwLock::new(BTreeMap::new()),
|
||||
user_input_requested_during_turn: AtomicBool::new(false),
|
||||
@@ -160,12 +162,13 @@ impl TurnMetadataState {
|
||||
&self,
|
||||
context: McpTurnMetadataContext<'_>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let Value::Object(mut metadata) =
|
||||
self.responses_metadata_template().turn_metadata_value()?
|
||||
else {
|
||||
let mut responses_metadata = self.responses_metadata_template();
|
||||
// Never serialize harness-owned tool inventory for external MCP servers.
|
||||
responses_metadata.code_mode_tool_names = None;
|
||||
responses_metadata.tool_namespaces_info = None;
|
||||
let Value::Object(mut metadata) = responses_metadata.turn_metadata_value()? else {
|
||||
return None;
|
||||
};
|
||||
metadata.remove(CODE_MODE_TOOL_NAMES_KEY); // Precaution: avoid exposing tool data to external MCPs.
|
||||
metadata.remove(PARENT_TURN_ID_KEY);
|
||||
metadata.insert(
|
||||
MODEL_KEY.to_string(),
|
||||
@@ -226,6 +229,14 @@ impl TurnMetadataState {
|
||||
(!code_mode_tool_names.is_empty()).then_some(code_mode_tool_names);
|
||||
}
|
||||
|
||||
pub(crate) fn set_tool_namespaces_info(&self, tool_namespaces_info: TurnToolNamespacesInfo) {
|
||||
*self
|
||||
.tool_namespaces_info
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
(!tool_namespaces_info.is_empty()).then_some(tool_namespaces_info);
|
||||
}
|
||||
|
||||
pub(crate) fn set_parent_turn_id(&self, parent_turn_id: String) {
|
||||
if parent_turn_id.trim().is_empty() {
|
||||
return;
|
||||
@@ -268,6 +279,11 @@ impl TurnMetadataState {
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
tool_namespaces_info: self
|
||||
.tool_namespaces_info
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone(),
|
||||
turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(),
|
||||
extra: self
|
||||
.responsesapi_client_metadata
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::responses_metadata::CodexResponsesRequestKind;
|
||||
use crate::responses_metadata::CompactionTurnMetadata;
|
||||
use crate::responses_metadata::INSTALLATION_ID_KEY;
|
||||
use crate::responses_metadata::PARENT_TURN_ID_KEY;
|
||||
use crate::responses_metadata::TOOL_NAMESPACES_INFO_KEY;
|
||||
use crate::responses_metadata::TurnToolFunctionInfo;
|
||||
use crate::responses_metadata::TurnToolNamespaceInfo;
|
||||
use crate::responses_metadata::TurnToolSource;
|
||||
use crate::responses_metadata::WINDOW_ID_KEY;
|
||||
use crate::sandbox_tags::permission_profile_sandbox_tag;
|
||||
use codex_analytics::CompactionImplementation;
|
||||
@@ -549,6 +553,10 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
|
||||
CODE_MODE_TOOL_NAMES_KEY.to_string(),
|
||||
"client-supplied".to_string(),
|
||||
),
|
||||
(
|
||||
TOOL_NAMESPACES_INFO_KEY.to_string(),
|
||||
"client-supplied".to_string(),
|
||||
),
|
||||
(
|
||||
"turn_started_at_unix_ms".to_string(),
|
||||
"client-supplied".to_string(),
|
||||
@@ -569,6 +577,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
|
||||
let json: Value = serde_json::from_str(&header).expect("json");
|
||||
|
||||
assert!(json.get(CODE_MODE_TOOL_NAMES_KEY).is_none());
|
||||
assert!(json.get(TOOL_NAMESPACES_INFO_KEY).is_none());
|
||||
assert!(json.get("turn_started_at_unix_ms").is_none());
|
||||
assert!(json.get("forked_from_thread_id").is_none());
|
||||
assert!(json.get("parent_thread_id").is_none());
|
||||
@@ -644,6 +653,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
|
||||
CODE_MODE_TOOL_NAMES_KEY.to_string(),
|
||||
"client-supplied".to_string(),
|
||||
),
|
||||
(
|
||||
TOOL_NAMESPACES_INFO_KEY.to_string(),
|
||||
"client-supplied".to_string(),
|
||||
),
|
||||
("turn_id".to_string(), "client-supplied".to_string()),
|
||||
(WINDOW_ID_KEY.to_string(), "client-supplied".to_string()),
|
||||
("thread_source".to_string(), "client-supplied".to_string()),
|
||||
@@ -661,6 +674,24 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
|
||||
ToolName::namespaced("mcp__calendar", "lookup"),
|
||||
),
|
||||
]));
|
||||
state.set_tool_namespaces_info(BTreeMap::from([(
|
||||
"mcp__calendar".to_string(),
|
||||
TurnToolNamespaceInfo {
|
||||
name: "mcp__calendar".to_string(),
|
||||
functions: BTreeMap::from([(
|
||||
"lookup".to_string(),
|
||||
TurnToolFunctionInfo {
|
||||
name: "lookup".to_string(),
|
||||
direct: true,
|
||||
code_mode_name: Some("mcp__calendar__lookup".to_string()),
|
||||
deferred: false,
|
||||
source: TurnToolSource::Mcp {
|
||||
server_name: "calendar".to_string(),
|
||||
},
|
||||
},
|
||||
)]),
|
||||
},
|
||||
)]));
|
||||
|
||||
let header = test_turn_metadata_header(&state);
|
||||
assert!(header.is_ascii());
|
||||
@@ -687,6 +718,26 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
json[TOOL_NAMESPACES_INFO_KEY],
|
||||
serde_json::json!({
|
||||
"mcp__calendar": {
|
||||
"name": "mcp__calendar",
|
||||
"functions": {
|
||||
"lookup": {
|
||||
"name": "lookup",
|
||||
"direct": true,
|
||||
"code_mode_name": "mcp__calendar__lookup",
|
||||
"deferred": false,
|
||||
"source": {
|
||||
"kind": "mcp",
|
||||
"server_name": "calendar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
assert!(json.get(INSTALLATION_ID_KEY).is_none());
|
||||
assert!(json.get("x-codex-installation-id").is_none());
|
||||
assert!(json.get("x-codex-parent-thread-id").is_none());
|
||||
@@ -727,12 +778,39 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
|
||||
Some("thread-a:1")
|
||||
);
|
||||
|
||||
let compatibility_headers = state
|
||||
.to_responses_metadata(
|
||||
"installation-a".to_string(),
|
||||
"thread-a:1".to_string(),
|
||||
CodexResponsesRequestKind::Turn,
|
||||
)
|
||||
.compatibility_headers();
|
||||
let compatibility_metadata: Value = serde_json::from_str(
|
||||
compatibility_headers
|
||||
.get("x-codex-turn-metadata")
|
||||
.expect("compatibility turn metadata header")
|
||||
.to_str()
|
||||
.expect("valid compatibility header"),
|
||||
)
|
||||
.expect("compatibility metadata json");
|
||||
assert!(
|
||||
compatibility_metadata
|
||||
.get(CODE_MODE_TOOL_NAMES_KEY)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
compatibility_metadata
|
||||
.get(TOOL_NAMESPACES_INFO_KEY)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let meta = state
|
||||
.current_meta_value_for_mcp_request(test_mcp_turn_metadata_context())
|
||||
.expect("turn metadata should be present");
|
||||
assert_eq!(meta["model"].as_str(), Some("gpt-5.4"));
|
||||
assert_eq!(meta["reasoning_effort"].as_str(), Some("high"));
|
||||
assert!(meta.get(CODE_MODE_TOOL_NAMES_KEY).is_none());
|
||||
assert!(meta.get(TOOL_NAMESPACES_INFO_KEY).is_none());
|
||||
assert!(meta.get(PARENT_TURN_ID_KEY).is_none());
|
||||
assert!(meta.get(WINDOW_ID_KEY).is_none());
|
||||
assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_features::Feature;
|
||||
use codex_image_generation_extension::install as install_image_generation_extension;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth::BedrockApiKeyAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
@@ -18,10 +19,15 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_web_search_extension::install as install_web_search_extension;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
|
||||
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
|
||||
use core_test_support::apps_test_server::apps_enabled_builder;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -158,11 +164,76 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
|
||||
"namespace": null,
|
||||
})
|
||||
);
|
||||
assert!(turn_metadata.get("tool_namespaces_info").is_none());
|
||||
assert!(!client_metadata.contains_key("x-codex-code-mode-tool-names"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_lite_includes_tool_namespaces_info_when_enabled() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount(&server).await?;
|
||||
let response_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url)
|
||||
.with_model_info_override("gpt-5.4", |model_info| {
|
||||
model_info.use_responses_lite = true;
|
||||
model_info.tool_mode = Some(ToolMode::CodeMode);
|
||||
model_info.supports_search_tool = false;
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.tool_registry.turn_metadata_includes_tool_info = true;
|
||||
});
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let request = response_mock.single_request();
|
||||
let body = request.body_json();
|
||||
let turn_metadata: Value = serde_json::from_str(
|
||||
body["client_metadata"]["x-codex-turn-metadata"]
|
||||
.as_str()
|
||||
.context("Responses request should include turn metadata")?,
|
||||
)?;
|
||||
let calendar = &turn_metadata["tool_namespaces_info"][SEARCH_CALENDAR_NAMESPACE];
|
||||
|
||||
assert_eq!(calendar["name"], SEARCH_CALENDAR_NAMESPACE);
|
||||
assert_eq!(
|
||||
calendar["functions"][SEARCH_CALENDAR_CREATE_TOOL],
|
||||
serde_json::json!({
|
||||
"name": SEARCH_CALENDAR_CREATE_TOOL,
|
||||
"direct": true,
|
||||
"deferred": false,
|
||||
"code_mode_name": "mcp__codex_apps__calendar_create_event",
|
||||
"source": {
|
||||
"kind": "mcp",
|
||||
"server_name": "codex_apps",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
let compatibility_metadata: Value = serde_json::from_str(
|
||||
request
|
||||
.header("x-codex-turn-metadata")
|
||||
.as_deref()
|
||||
.context("Responses request should include compatibility turn metadata")?,
|
||||
)?;
|
||||
assert!(compatibility_metadata.get("tool_namespaces_info").is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_lite_prepares_images() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user