From f742dabc6f9c575ca43428a84b66fb42a7f3e4b2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 28 Aug 2026 20:43:31 +0000 Subject: [PATCH] Support per-tool MCP output limits (#41421) ## What changed - Add a positive `output_token_limit` setting to each entry under an MCP server's `tools` configuration. - Apply the most restrictive limit when plugin and user policies overlap, while keeping approval policy independent. - Carry the effective MCP output budget in conversation history so tool output, post-tool hook responses, and resumed sessions use the same truncation limit. ## Testing - Cover configuration parsing, serialization, schema validation, and plugin policy merging. - Cover MCP output below and above the configured limit, post-tool hook responses, and session resume. GitOrigin-RevId: d0beb4fca9ba6055d9e1d31c137373b465d50d61 --- .../app-server-exports-stable.json.zst | Bin 144099 -> 144176 bytes codex-rs/codex-mcp/src/binding.rs | 12 + codex-rs/codex-mcp/src/catalog_tests.rs | 1 + .../codex-mcp/src/connection_manager_tests.rs | 1 + codex-rs/config/src/mcp_types.rs | 16 +- codex-rs/config/src/mcp_types_tests.rs | 15 ++ codex-rs/config/src/types.rs | 2 +- codex-rs/core-plugins/src/loader.rs | 9 +- codex-rs/core-plugins/src/manager_tests.rs | 4 +- codex-rs/core/config.schema.json | 10 +- codex-rs/core/src/compact_remote_v2.rs | 2 + .../compact_remote_v2_image_budget_tests.rs | 1 + codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/edit.rs | 21 +- .../core/src/config/edit/document_helpers.rs | 25 ++- codex-rs/core/src/config/edit_tests.rs | 25 ++- codex-rs/core/src/context_manager/history.rs | 88 ++------ .../core/src/context_manager/history_tests.rs | 36 ++- codex-rs/core/src/context_manager/mod.rs | 1 - codex-rs/core/src/mcp_tool_call_tests.rs | 4 + codex-rs/core/src/session/inject.rs | 5 +- codex-rs/core/src/session/input_queue.rs | 1 + codex-rs/core/src/session/turn.rs | 17 +- codex-rs/core/src/stream_events_utils.rs | 3 +- codex-rs/core/src/tools/context.rs | 21 +- codex-rs/core/src/tools/context_tests.rs | 14 +- codex-rs/core/src/tools/handlers/mcp.rs | 9 +- codex-rs/core/src/tools/parallel.rs | 12 +- codex-rs/core/src/tools/registry.rs | 18 +- codex-rs/core/src/tools/registry_tests.rs | 64 +++++- codex-rs/core/src/tools/router_tests.rs | 9 +- codex-rs/core/tests/suite/hooks_mcp.rs | 2 +- codex-rs/core/tests/suite/truncation.rs | 206 ++++++++++++------ codex-rs/ext/mcp/tests/executor_plugin_mcp.rs | 11 +- codex-rs/history/src/lib.rs | 5 + codex-rs/history/src/tests.rs | 7 +- codex-rs/tools/src/tool_output.rs | 10 + codex-rs/utils/output-truncation/src/lib.rs | 22 +- 38 files changed, 487 insertions(+), 224 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 6c39f60fd673dc570f0ddf3971146d7fa3328b5d..bec977fae9bf7d3cf184c266ff66502f55db4860 100644 GIT binary patch delta 17954 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 P$5ziDLaTy(wMQr@d=of; diff --git a/codex-rs/codex-mcp/src/binding.rs b/codex-rs/codex-mcp/src/binding.rs index c6d79fea0d..4fbe33a433 100644 --- a/codex-rs/codex-mcp/src/binding.rs +++ b/codex-rs/codex-mcp/src/binding.rs @@ -268,6 +268,18 @@ impl PreparedMcpCall { .tool_approval_mode(&self.tool_info.tool.name) } + /// Returns the explicit output budget captured with this call's effective server config. + pub fn output_token_limit(&self) -> Option { + self.config + .mcp_server_catalog + .server(&self.server_name)? + .config() + .tools + .get(self.tool_info.tool.name.as_ref())? + .output_token_limit + .map(std::num::NonZeroUsize::get) + } + pub fn plugin_id(&self) -> Option<&str> { self.plugin_id.as_deref() } diff --git a/codex-rs/codex-mcp/src/catalog_tests.rs b/codex-rs/codex-mcp/src/catalog_tests.rs index ef41e110a8..896b2d4522 100644 --- a/codex-rs/codex-mcp/src/catalog_tests.rs +++ b/codex-rs/codex-mcp/src/catalog_tests.rs @@ -51,6 +51,7 @@ fn server(url: &str) -> McpServerConfig { "read".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() }, )]), } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index ee20c57598..8388d49c73 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -3966,6 +3966,7 @@ fn server_metadata_preserves_tool_approval_policy() { "search".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() }, ); let metadata = McpServerMetadata::from(&EffectiveMcpServer::configured(config)); diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 4716f03494..5d18433be1 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::fmt; +use std::num::NonZeroUsize; use std::time::Duration; use base64::Engine; @@ -71,13 +72,24 @@ impl fmt::Display for McpServerDisabledReason { } } -/// Per-tool approval settings for a single MCP server tool. +/// Per-tool settings for a single MCP server tool. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct McpServerToolConfig { /// Approval mode for this tool. #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_mode: Option, + + /// Token budget for this tool's output, before the standard 20% serialization allowance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_token_limit: Option, +} + +impl McpServerToolConfig { + /// Applies the stricter explicit output budget without changing approval policy. + pub fn restrict_output_token_limit(&mut self, limit: Option) { + self.output_token_limit = self.output_token_limit.into_iter().chain(limit).min(); + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] @@ -250,7 +262,7 @@ pub struct McpServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth_resource: Option, - /// Per-tool approval settings keyed by tool name. + /// Per-tool settings keyed by tool name. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub tools: HashMap, } diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index d0aafaf54d..f7a70787c5 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -480,6 +480,7 @@ fn deserialize_server_config_with_default_tool_approval_mode() { [tools.search] approval_mode = "prompt" + output_token_limit = 30000 "#, ) .expect("should deserialize default tool approval mode"); @@ -492,17 +493,31 @@ fn deserialize_server_config_with_default_tool_approval_mode() { cfg.tools.get("search"), Some(&McpServerToolConfig { approval_mode: Some(AppToolApproval::Prompt), + output_token_limit: std::num::NonZeroUsize::new(30_000), }) ); let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); assert!(serialized.contains("default_tools_approval_mode = \"approve\"")); + assert!(serialized.contains("output_token_limit = 30000")); let round_tripped: McpServerConfig = toml::from_str(&serialized).expect("should deserialize serialized MCP config"); assert_eq!(round_tripped, cfg); } +#[test] +fn deserialize_rejects_nonpositive_mcp_tool_output_limits() { + for output_token_limit in [0, -1] { + let config = format!( + "command = \"echo\"\n[tools.search]\noutput_token_limit = {output_token_limit}\n" + ); + let error = toml::from_str::(&config) + .expect_err("MCP tool output limit must be positive"); + assert!(error.to_string().contains("output_token_limit")); + } +} + #[test] fn serialize_round_trips_server_config_with_parallel_tool_calls() { let cfg: McpServerConfig = toml::from_str( diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 4e01557be5..2eb73e577a 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -879,7 +879,7 @@ pub struct PluginMcpServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_tools: Option>, - /// Per-tool approval settings keyed by tool name. + /// Per-tool policy settings keyed by tool name. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub tools: HashMap, } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 0c6ce406b6..752795e668 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -974,6 +974,7 @@ fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginM if let Some(approval_mode) = tool_policy.approval_mode { tool_config.approval_mode = Some(approval_mode); } + tool_config.restrict_output_token_limit(tool_policy.output_token_limit); } } @@ -1449,7 +1450,7 @@ pub fn apply_configured_plugin_mcp_server_policies( } } for (tool_name, tool_policy) in &policy.tools { - if tool_policy.approval_mode.is_some() { + if tool_policy.approval_mode.is_some() || tool_policy.output_token_limit.is_some() { server.tools.entry(tool_name.clone()).or_default(); } } @@ -1467,6 +1468,12 @@ pub fn apply_configured_plugin_mcp_server_policies( .restrict_to(approval_mode), ); } + tool_config.restrict_output_token_limit( + policy + .tools + .get(tool_name) + .and_then(|tool_policy| tool_policy.output_token_limit), + ); } } } diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 008ada15f4..8e1bf449ef 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1113,7 +1113,7 @@ async fn load_plugins_applies_plugin_mcp_server_policy() { "default_tools_approval_mode": "prompt", "enabled_tools": ["read", "search"], "tools": { - "search": { "approval_mode": "prompt" } + "search": { "approval_mode": "prompt", "output_token_limit": 8000 } } } } @@ -1134,6 +1134,7 @@ disabled_tools = ["delete"] [plugins."sample@test".mcp_servers.sample.tools.search] approval_mode = "approve" +output_token_limit = 12000 "#; let outcome = @@ -1154,6 +1155,7 @@ approval_mode = "approve" server.tools.get("search"), Some(&McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + output_token_limit: std::num::NonZeroUsize::new(8_000), }) ); } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1d8a9f4e1f..9b453c0db9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1966,7 +1966,7 @@ }, "McpServerToolConfig": { "additionalProperties": false, - "description": "Per-tool approval settings for a single MCP server tool.", + "description": "Per-tool settings for a single MCP server tool.", "properties": { "approval_mode": { "allOf": [ @@ -1975,6 +1975,12 @@ } ], "description": "Approval mode for this tool." + }, + "output_token_limit": { + "description": "Token budget for this tool's output, before the standard 20% serialization allowance.", + "format": "uint", + "minimum": 1.0, + "type": "integer" } }, "type": "object" @@ -2990,7 +2996,7 @@ "additionalProperties": { "$ref": "#/definitions/McpServerToolConfig" }, - "description": "Per-tool approval settings keyed by tool name.", + "description": "Per-tool policy settings keyed by tool name.", "type": "object" } }, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 331751c716..adc4b36c0d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -882,6 +882,7 @@ mod tests { None, Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), Some(CodexHarnessMetadata::default()), None, @@ -905,6 +906,7 @@ mod tests { item: client.clone(), metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }, ); diff --git a/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs b/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs index 30f44b952e..ffc3a6f85a 100644 --- a/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs +++ b/codex-rs/core/src/compact_remote_v2_image_budget_tests.rs @@ -147,6 +147,7 @@ fn image_treatment_preserves_client_developer_boundary_behavior() { item, metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }; for max_tokens in [1871, 1879, 1880, 1950, 2200] { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3a4554456f..113686e856 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6773,6 +6773,7 @@ approval_mode = "approve" server.tools.get("search"), Some(&McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() }) ); } @@ -6816,6 +6817,7 @@ approval_mode = "approve" tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() } ); } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 6d13d9a755..52447014fe 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -314,7 +314,7 @@ impl ConfigDocument { &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), - ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), + ConfigEdit::ReplaceMcpServers(servers) => self.replace_mcp_servers(servers), ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => { Ok(self.add_tool_suggest_disabled_tool(disabled_tool)) } @@ -432,9 +432,12 @@ impl ConfigDocument { self.remove(segments) } - fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { + fn replace_mcp_servers( + &mut self, + servers: &BTreeMap, + ) -> anyhow::Result { if servers.is_empty() { - return self.clear(&["mcp_servers"]); + return Ok(self.clear(&["mcp_servers"])); } let root = self.doc.as_table_mut(); @@ -446,7 +449,7 @@ impl ConfigDocument { } let Some(item) = root.get_mut("mcp_servers") else { - return false; + return Ok(false); }; if document_helpers::ensure_table_for_write(item).is_none() { @@ -454,7 +457,7 @@ impl ConfigDocument { } let Some(table) = item.as_table_mut() else { - return false; + return Ok(false); }; let keys_to_remove: Vec = table @@ -472,17 +475,17 @@ impl ConfigDocument { if let TomlItem::Value(value) = existing && let Some(inline) = value.as_inline_table_mut() { - let replacement = document_helpers::serialize_mcp_server_inline(config); + let replacement = document_helpers::serialize_mcp_server_inline(config)?; document_helpers::merge_inline_table(inline, replacement); } else { - *existing = document_helpers::serialize_mcp_server(config); + *existing = document_helpers::serialize_mcp_server(config)?; } } else { - table.insert(name, document_helpers::serialize_mcp_server(config)); + table.insert(name, document_helpers::serialize_mcp_server(config)?); } } - true + Ok(true) } fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 60883137d0..acf598bca1 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use codex_config::types::AppToolApproval; use codex_config::types::McpServerAuth; use codex_config::types::McpServerConfig; @@ -45,7 +46,7 @@ pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTabl } } -fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { +fn serialize_mcp_server_table(config: &McpServerConfig) -> anyhow::Result { let mut entry = TomlTable::new(); entry.set_implicit(false); @@ -175,15 +176,15 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { let mut tool_entries: Vec<_> = config.tools.iter().collect(); tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { - tools.insert(name, serialize_mcp_server_tool(tool_config)); + tools.insert(name, serialize_mcp_server_tool(tool_config)?); } entry.insert("tools", TomlItem::Table(tools)); } - entry + Ok(entry) } -fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { +fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> anyhow::Result { let mut entry = TomlTable::new(); entry.set_implicit(false); if let Some(approval_mode) = config.approval_mode { @@ -194,15 +195,21 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { AppToolApproval::Approve => "approve", }); } - TomlItem::Table(entry) + if let Some(output_token_limit) = config.output_token_limit { + entry["output_token_limit"] = value( + i64::try_from(output_token_limit.get()) + .context("output_token_limit exceeds the TOML integer range")?, + ); + } + Ok(TomlItem::Table(entry)) } -pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { - TomlItem::Table(serialize_mcp_server_table(config)) +pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> anyhow::Result { + Ok(TomlItem::Table(serialize_mcp_server_table(config)?)) } -pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { - serialize_mcp_server_table(config).into_inline_table() +pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> anyhow::Result { + Ok(serialize_mcp_server_table(config)?.into_inline_table()) } pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 7271ce78b5..19434c39f2 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1161,8 +1161,9 @@ B = \"2\" assert_eq!(raw, expected); } -#[test] -fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { +#[test_case::test_case(30_000; "positive limit")] +#[test_case::test_case(usize::MAX; "largest native limit")] +fn blocking_replace_mcp_servers_serializes_tool_approval_overrides(output_token_limit: usize) { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -1196,22 +1197,36 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { "search".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + output_token_limit: std::num::NonZeroUsize::new(output_token_limit), }, )]), }, ); - apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + let result = apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]); + if i64::try_from(output_token_limit).is_err() { + assert!( + result + .unwrap_err() + .to_string() + .contains("output_token_limit exceeds the TOML integer range") + ); + return; + } + result.expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = "\ + let expected = format!( + "\ [mcp_servers.docs] command = \"docs-server\" default_tools_approval_mode = \"prompt\" [mcp_servers.docs.tools.search] approval_mode = \"approve\" -"; +output_token_limit = {output_token_limit} +" + ); assert_eq!(raw, expected); } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 954e185283..1b62e756b8 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -21,7 +21,6 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; @@ -37,8 +36,7 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_bytes_for_tokens; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::approx_tokens_from_byte_count_i64; -use codex_utils_output_truncation::truncate_function_output_items_with_policy; -use codex_utils_output_truncation::truncate_text; +use codex_utils_output_truncation::truncate_function_output_payload; use std::num::NonZeroUsize; use std::ops::Deref; use std::sync::Arc; @@ -171,7 +169,7 @@ impl ContextManager { self.record_items_with_metadata(items.into_iter().map(|item| (item, None)), policy); } - /// Records history envelopes while preserving their history-only metadata. + /// Records output while preserving its history-only metadata. pub(crate) fn record_annotated_items( &mut self, items: &[ResponseItemEnvelope], @@ -196,10 +194,20 @@ impl ContextManager { continue; } - let processed = ResponseItemEnvelope { - item: Self::process_item(item, policy), + let mut processed = ResponseItemEnvelope { + item: item.clone(), metadata: metadata.cloned(), }; + if let ResponseItem::FunctionCallOutput { output, .. } + | ResponseItem::CustomToolCallOutput { output, .. } = &mut processed.item + { + // The override already includes the tool's serialization allowance. + let policy = metadata + .and_then(|metadata| metadata.fallback_token_limit_override) + .map(TruncationPolicy::Tokens) + .unwrap_or(policy * 1.2); + truncate_function_output_payload(output, policy, estimate_audio_token_count); + } Arc::make_mut(&mut self.items).push(processed); } } @@ -473,55 +481,6 @@ impl ContextManager { normalize::strip_audio_when_unsupported(input_modalities, items); } - fn process_item(item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem { - let policy_with_serialization_budget = policy * 1.2; - match item { - ResponseItem::FunctionCallOutput { - id, - call_id, - name, - namespace, - output, - internal_chat_message_metadata_passthrough: metadata, - } => ResponseItem::FunctionCallOutput { - id: id.clone(), - call_id: call_id.clone(), - name: name.clone(), - namespace: namespace.clone(), - output: truncate_function_output_payload(output, policy_with_serialization_budget), - internal_chat_message_metadata_passthrough: metadata.clone(), - }, - ResponseItem::CustomToolCallOutput { - id, - call_id, - name, - output, - internal_chat_message_metadata_passthrough: metadata, - } => ResponseItem::CustomToolCallOutput { - id: id.clone(), - call_id: call_id.clone(), - name: name.clone(), - output: truncate_function_output_payload(output, policy_with_serialization_budget), - internal_chat_message_metadata_passthrough: metadata.clone(), - }, - ResponseItem::AdditionalTools { .. } - | ResponseItem::Message { .. } - | ResponseItem::AgentMessage { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::CompactionTrigger { .. } - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other => item.clone(), - } - } - /// Walk backward from a rollback cut and trim contiguous pre-turn context-update items. /// /// Returns the adjusted cut index after removing contextual developer/user items immediately @@ -570,25 +529,6 @@ impl ContextManager { } } -pub(crate) fn truncate_function_output_payload( - output: &FunctionCallOutputPayload, - policy: TruncationPolicy, -) -> FunctionCallOutputPayload { - let body = match &output.body { - FunctionCallOutputBody::Text(content) => { - FunctionCallOutputBody::Text(truncate_text(content, policy)) - } - FunctionCallOutputBody::ContentItems(items) => FunctionCallOutputBody::ContentItems( - truncate_function_output_items_with_policy(items, policy, estimate_audio_token_count), - ), - }; - - FunctionCallOutputPayload { - body, - success: output.success, - } -} - /// API messages include every non-system item (user/assistant messages, reasoning, /// tool calls, tool outputs, shell calls, web-search calls, and image-generation /// calls). diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index cdf5b3da97..2c661588f6 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -41,6 +41,7 @@ use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; +use test_case::test_case; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -526,8 +527,16 @@ fn annotated_history_apis_preserve_envelopes() { assert_eq!(history.into_raw_items(), vec![first_item]); } -#[test] -fn record_annotated_items_preserves_metadata_while_processing_item() { +#[test_case(None, 100, 5, true; "model policy")] +#[test_case(Some(200), 100, 200, false; "configured override")] +#[test_case(Some(100), 85, 100, true; "saved limit has no additional allowance")] +#[test_case(Some(30_000), 20_000, 30_000, false; "large explicit budget")] +fn record_annotated_items_preserves_metadata_while_processing_item( + fallback_token_limit_override: Option, + repeat_count: usize, + expected_token_limit: usize, + expected_truncation: bool, +) { let envelope = ResponseItemEnvelope { item: ResponseItem::FunctionCallOutput { id: None, @@ -535,23 +544,36 @@ fn record_annotated_items_preserves_metadata_while_processing_item() { name: None, namespace: None, output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text("word ".repeat(100)), + body: FunctionCallOutputBody::Text("word ".repeat(repeat_count)), success: Some(true), }, internal_chat_message_metadata_passthrough: None, }, - metadata: Some(CodexHarnessMetadata::default()), + metadata: Some(CodexHarnessMetadata { + fallback_token_limit_override, + ..Default::default() + }), }; let mut history = ContextManager::new(); history.record_annotated_items(std::slice::from_ref(&envelope), TruncationPolicy::Tokens(4)); assert_eq!(history.annotated_items().len(), 1); + assert_eq!(history.annotated_items()[0].metadata, envelope.metadata); assert_eq!( - history.annotated_items()[0].metadata, - Some(CodexHarnessMetadata::default()) + history.annotated_items()[0].item != envelope.item, + expected_truncation + ); + let ResponseItem::FunctionCallOutput { output, .. } = &history.annotated_items()[0].item else { + panic!("expected function call output"); + }; + assert_eq!( + output.body, + FunctionCallOutputBody::Text(truncate_text( + &"word ".repeat(repeat_count), + TruncationPolicy::Tokens(expected_token_limit), + )) ); - assert_ne!(history.annotated_items()[0].item, envelope.item); } #[test] diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 97cd7c2109..e67eb7113f 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -6,4 +6,3 @@ pub(crate) use history::ContextManager; pub(crate) use history::estimate_image_bytes; pub(crate) use history::estimate_item_token_count; pub(crate) use history::is_user_turn_boundary; -pub(crate) use history::truncate_function_output_payload; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 4833da53f3..80b0794b7a 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2102,6 +2102,7 @@ async fn persist_custom_mcp_tool_approval_writes_tool_override() { tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() } ); assert!(contents.contains("[mcp_servers.docs.tools.search]")); @@ -2319,6 +2320,7 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() } ); assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); @@ -2368,6 +2370,7 @@ enabled = true tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() } ); assert!(contents.contains(r#"[plugins."sample@test".mcp_servers.sample.tools.search]"#)); @@ -2423,6 +2426,7 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve tool, &McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() } ); assert!(contents.contains("[mcp_servers.docs.tools.search]")); diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 6e51aa40e2..8c17a8ae5e 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -72,6 +72,7 @@ impl Session { && matches!(&item, ResponseItem::Message { role, .. } if role == "developer")) .then_some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }); ResponseItemEnvelope { item, metadata } @@ -82,9 +83,7 @@ impl Session { turn_context: &TurnContext, items: Vec, ) { - if !self.enabled(Feature::RetainClientDeveloperMessages) - || items.iter().all(|item| item.metadata.is_none()) - { + if items.iter().all(|item| item.metadata.is_none()) { let items = items .into_iter() .map(ResponseItemEnvelope::into_item) diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index e7bd6642bd..6e71857aa3 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -401,6 +401,7 @@ mod tests { item: ResponseItem::Other, metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }); assert!(serde_json::to_value(annotated).is_err()); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1583f2d9b0..8636a4bd8f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -40,6 +40,7 @@ use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::skills::emit_explicit_skill_invocations; use crate::stream_events_utils::HandleOutputCtx; +use crate::stream_events_utils::InFlightFuture; use crate::stream_events_utils::TurnItemContributorPolicy; use crate::stream_events_utils::finalize_non_tool_response_item; use crate::stream_events_utils::handle_non_tool_response_item; @@ -90,7 +91,6 @@ use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; @@ -124,7 +124,6 @@ use codex_utils_stream_parser::AssistantTextStreamParser; use codex_utils_stream_parser::ProposedPlanSegment; use codex_utils_stream_parser::extract_proposed_plan_text; use codex_utils_stream_parser::strip_citations; -use futures::future::BoxFuture; use futures::prelude::*; use futures::stream::FuturesOrdered; use tokio_util::sync::CancellationToken; @@ -2154,22 +2153,21 @@ async fn handle_assistant_item_done_in_plan_mode( #[instrument(level = "trace", skip_all)] async fn drain_in_flight( - in_flight: &mut FuturesOrdered>>, + in_flight: &mut FuturesOrdered>, sess: Arc, turn_context: Arc, ) -> CodexResult<()> { while let Some(res) = in_flight.next().await { match res { - Ok(response_input) => { - let response_item = response_input.into(); - sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item)) - .await; + Ok(envelope) => { mark_thread_memory_mode_polluted_if_external_context( sess.as_ref(), turn_context.as_ref(), - &response_item, + &envelope.item, ) .await; + sess.record_annotated_conversation_items(&turn_context, vec![envelope]) + .await; } Err(err) => { error_or_panic(format!("in-flight tool future failed during drain: {err}")); @@ -2247,8 +2245,7 @@ async fn try_run_sampling_request( .instrument(trace_span!("stream_request")) .or_cancel(&cancellation_token) .await??; - let mut in_flight: FuturesOrdered>> = - FuturesOrdered::new(); + let mut in_flight: FuturesOrdered> = FuturesOrdered::new(); let mut needs_follow_up = false; let mut last_agent_message: Option = None; let mut active_item: Option = None; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 5445313656..a75dbe2af8 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -2,6 +2,7 @@ use std::pin::Pin; use std::sync::Arc; use codex_extension_api::ExtensionData; +use codex_history::ResponseItemEnvelope; use codex_protocol::ResponseItemId; use codex_protocol::config_types::ModeKind; use codex_protocol::items::TurnItem; @@ -191,7 +192,7 @@ async fn record_stage1_output_usage_for_memory_citation( /// queuing any tool execution futures. This records items immediately so /// history and rollout stay in sync even if the turn is later cancelled. pub(crate) type InFlightFuture<'f> = - Pin> + Send + 'f>>; + Pin> + Send + 'f>>; #[derive(Default)] pub(crate) struct OutputItemResult { diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 378f947058..72f9481ff3 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -1,4 +1,3 @@ -use crate::context_manager::truncate_function_output_payload; use crate::original_image_detail::sanitize_original_image_detail; use crate::session::session::Session; use crate::session::step_context::StepContext; @@ -16,9 +15,11 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::function_call_output_content_items_to_text; use codex_tools::LoadableToolSpec; use codex_tools::ToolName; +use codex_utils_audio::estimate_audio_token_count; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::formatted_truncate_text; +use codex_utils_output_truncation::truncate_function_output_payload; use codex_utils_output_truncation::truncate_text; use serde::Serialize; use serde_json::Value as JsonValue; @@ -122,6 +123,10 @@ impl ToolOutput for McpToolOutput { self.result.success() } + fn fallback_token_limit_override(&self) -> Option { + Some((self.truncation_policy * 1.2).token_budget()) + } + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), @@ -165,13 +170,13 @@ impl McpToolOutput { } } - // This is the context-injection form, so keep it aligned with the - // function-call output truncation that conversation history already - // applies. Code-mode consumers still get the raw `CallToolResult`. - // - // The text is serialized again inside the Responses payload, so allow - // a small buffer for JSON escaping and wrapper overhead. - truncate_function_output_payload(&payload, self.truncation_policy * 1.2) + // History receives this budget in tokens. Code Mode keeps the raw result. + truncate_function_output_payload( + &mut payload, + self.truncation_policy * 1.2, + estimate_audio_token_count, + ); + payload } } diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 3011b2ef51..391b8b0c03 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -230,8 +230,11 @@ fn mcp_tool_output_response_item_preserves_content_items() { } } -#[test] -fn mcp_tool_output_code_mode_result_preserves_content_without_private_metadata() { +#[test_case::test_case(TruncationPolicy::Bytes(64); "byte budget")] +#[test_case::test_case(TruncationPolicy::Tokens(1); "token budget")] +fn mcp_tool_output_code_mode_result_preserves_content_without_private_metadata( + truncation_policy: TruncationPolicy, +) { let large_content = "large structured value ".repeat(1_000); let output = McpToolOutput { result: CallToolResult { @@ -250,12 +253,13 @@ fn mcp_tool_output_code_mode_result_preserves_content_without_private_metadata() tool_input: json!({}), wall_time: std::time::Duration::from_millis(1250), original_image_detail_supported: false, - truncation_policy: TruncationPolicy::Bytes(64), + truncation_policy, }; - let result = output.code_mode_result(&ToolPayload::Function { + let payload = ToolPayload::Function { arguments: "{}".to_string(), - }); + }; + let result = output.code_mode_result(&payload); assert_eq!( result, diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 9d84ddc36b..4d45c64349 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -37,6 +37,7 @@ use codex_tools::agent_plugin_mcp_tool_to_responses_api_tool; use codex_tools::mcp_tool_to_responses_api_tool; use codex_utils_image::PromptImageMode; use codex_utils_image::load_data_url_for_prompt_uncached; +use codex_utils_output_truncation::TruncationPolicy; use codex_utils_string::take_bytes_at_char_boundary; use futures::future::BoxFuture; use serde_json::Map; @@ -217,6 +218,12 @@ impl McpHandler { } }; + // Capture presentation policy from the same config snapshot used for execution. + let truncation_policy = prepared_mcp_call + .as_ref() + .and_then(codex_mcp::PreparedMcpCall::output_token_limit) + .map(TruncationPolicy::Tokens) + .unwrap_or(turn.model_info().truncation_policy.into()); let started = Instant::now(); let result = handle_mcp_tool_call( Arc::clone(&session), @@ -237,7 +244,7 @@ impl McpHandler { tool_input: result.tool_input, wall_time: started.elapsed(), original_image_detail_supported: can_request_original_image_detail(turn.model_info()), - truncation_policy: turn.model_info().truncation_policy.into(), + truncation_policy, })) } } diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 6b92c89270..67c065faee 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -25,6 +25,7 @@ use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; +use codex_history::ResponseItemEnvelope; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; @@ -74,7 +75,7 @@ impl ToolCallRuntime { self, call: ToolCall, cancellation_token: CancellationToken, - ) -> impl std::future::Future> { + ) -> impl std::future::Future> { let error_call = call.clone(); let source = call.direct_source(); let future = self.handle_tool_call_with_source(call, source, cancellation_token); @@ -82,7 +83,9 @@ impl ToolCallRuntime { match future.await { Ok(response) => Ok(response.into_response()), Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)), - Err(other) => Ok(Self::failure_response(error_call, other)), + Err(other) => Ok(ResponseItemEnvelope::new( + Self::failure_response(error_call, other).into(), + )), } } .in_current_span() @@ -655,7 +658,10 @@ mod tests { success: Some(true), }, }; - assert_eq!(expected_response, response); + assert_eq!( + ResponseItemEnvelope::new(expected_response.into()), + response + ); let actual = records .lock() diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 15c2cf27e5..6564a22d1f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,6 +30,8 @@ use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::util::error_or_panic; use codex_analytics::ControlToolCallStatus; use codex_extension_api::ToolCallOutcome; +use codex_history::CodexHarnessMetadata; +use codex_history::ResponseItemEnvelope; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::parse_command::ParsedCommand; @@ -191,14 +193,22 @@ pub(crate) struct AnyToolResult { } impl AnyToolResult { - pub(crate) fn into_response(self) -> ResponseInputItem { + pub(crate) fn into_response(self) -> ResponseItemEnvelope { let Self { call_id, payload, result, .. } = self; - result.to_response_item(&call_id, &payload) + ResponseItemEnvelope { + item: result.to_response_item(&call_id, &payload).into(), + metadata: result + .fallback_token_limit_override() + .map(|limit| CodexHarnessMetadata { + fallback_token_limit_override: Some(limit), + ..Default::default() + }), + } } pub(crate) fn code_mode_result(self) -> serde_json::Value { @@ -223,6 +233,10 @@ impl ToolOutput for PostToolUseFeedbackOutput { self.original.success_for_logging() } + fn fallback_token_limit_override(&self) -> Option { + self.original.fallback_token_limit_override() + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { self.model_visible.to_response_item(call_id, payload) } diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index b862988bec..65c19f6ae3 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::session::session::Session; use crate::session::step_context::StepContext; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; +use codex_protocol::models::ResponseItem; +use codex_utils_output_truncation::TruncationPolicy; use futures::future::BoxFuture; use pretty_assertions::assert_eq; use std::sync::atomic::AtomicUsize; @@ -587,6 +589,53 @@ async fn write_stdin_does_not_expose_default_pre_tool_use_payload() { assert_eq!(write_stdin.pre_tool_use_payload(&invocation), None); } +#[test_case::test_case(TruncationPolicy::Tokens(1), 2; "token budget")] +#[test_case::test_case(TruncationPolicy::Bytes(401), 121; "scale bytes before converting to tokens")] +fn post_tool_use_feedback_output_preserves_fallback_token_limit_override( + truncation_policy: TruncationPolicy, + expected_token_limit: usize, +) { + let result = AnyToolResult { + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + result: Box::new(PostToolUseFeedbackOutput { + original: Box::new(crate::tools::context::McpToolOutput { + result: codex_protocol::mcp::CallToolResult { + content: Vec::new(), + structured_content: None, + is_error: None, + meta: None, + }, + tool_input: serde_json::json!({}), + wall_time: Duration::ZERO, + original_image_detail_supported: false, + truncation_policy, + }), + model_visible: crate::tools::context::FunctionToolOutput::from_text( + "hook feedback".to_string(), + /*success*/ None, + ), + }), + post_tool_use_payload: None, + }; + + assert_eq!( + result.into_response(), + ResponseItemEnvelope { + item: ResponseItem::from(ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("hook feedback".to_string()), + }), + metadata: Some(CodexHarnessMetadata { + fallback_token_limit_override: Some(expected_token_limit), + ..Default::default() + }), + } + ); +} + #[test] fn post_tool_use_feedback_output_keeps_code_mode_result_typed() { let result = AnyToolResult { @@ -608,12 +657,15 @@ fn post_tool_use_feedback_output_keeps_code_mode_result_typed() { assert_eq!( result.into_response(), - ResponseInputItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: codex_protocol::models::FunctionCallOutputPayload::from_text( - "hook feedback".to_string() - ), - } + ResponseItemEnvelope::new( + ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: codex_protocol::models::FunctionCallOutputPayload::from_text( + "hook feedback".to_string() + ), + } + .into() + ) ); let result = AnyToolResult { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index d01de04d99..01935232b4 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -26,7 +26,6 @@ use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; @@ -604,10 +603,12 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow ) .await?; - let response = result.into_response(); + let response = result.into_response().item; match response { - ResponseInputItem::FunctionCallOutput { call_id, output } => { - assert_eq!(call_id, "call-extension"); + ResponseItem::FunctionCallOutput { + call_id, output, .. + } => { + assert_eq!(call_id.as_deref(), Some("call-extension")); let FunctionCallOutputBody::Text(text) = output.body else { panic!("expected text function call output") }; diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 5c81b00af1..2243b15778 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -233,7 +233,7 @@ print({python_output_literal}) Ok(()) } -fn write_mcp_tool_hook( +pub(super) fn write_mcp_tool_hook( home: &Path, event_name: &str, matcher: Option<&str>, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 974f59b9ff..ed1c77f0f7 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -26,7 +26,10 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -35,6 +38,11 @@ use serde_json::Value; use serde_json::json; use std::collections::HashMap; use std::time::Duration; +use test_case::test_case; +use wiremock::MockServer; + +use super::rmcp_client::remote_aware_environment_id; +use super::rmcp_client::remote_aware_stdio_server_bin; fn assert_wall_time_header(output: &str) { let (wall_time, marker) = output @@ -745,21 +753,19 @@ async fn exec_command_output_not_truncated_with_custom_limit() -> Result<()> { Ok(()) } -// MCP server output should also remain intact when the config increases the token limit. -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let call_id = "rmcp-untruncated"; +async fn call_mcp_echo( + server: &MockServer, + builder: TestCodexBuilder, + output_token_limit: Option, + message_bytes: usize, +) -> Result<(TestCodex, String)> { + let call_id = "rmcp-output"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); - let large_msg = "a".repeat(80_000); - let args_json = serde_json::json!({ "message": large_msg }); + let args_json = json!({ "message": "a".repeat(message_bytes) }); mount_sse_once( - &server, + server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call_with_namespace( @@ -772,8 +778,8 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { ]), ) .await; - let mock2 = mount_sse_once( - &server, + let response = mount_sse_once( + server, sse(vec![ responses::ev_assistant_message("msg-1", "rmcp echo tool completed."), responses::ev_completed("resp-2"), @@ -781,67 +787,139 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { ) .await; - let rmcp_test_server_bin = stdio_server_bin()?; - - let mut builder = test_codex().with_config(move |config| { - config.tool_output_token_limit = Some(50_000); - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - server_name.to_string(), - codex_config::types::McpServerConfig { - auth: Default::default(), - transport: codex_config::types::McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - environment_id: "local".to_string(), - enabled: true, - required: false, - supports_parallel_tool_calls: false, - omit_tools_from: None, - disabled_reason: None, - startup_timeout_sec: Some(std::time::Duration::from_secs(10)), - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth: None, - oauth_resource: None, - tools: HashMap::new(), - }, - ); + let mcp_server = serde_json::from_value(json!({ + "command": remote_aware_stdio_server_bin()?, + "environment_id": remote_aware_environment_id(), + "startup_timeout_sec": 10, + "tools": { "echo": { "output_token_limit": output_token_limit } }, + }))?; + let mut builder = builder.with_config(move |config| { config .mcp_servers - .set(servers) + .set(HashMap::from([(server_name.to_string(), mcp_server)])) .expect("test mcp servers should accept any configuration"); }); - let fixture = builder.build(&server).await?; + let fixture = builder.build_with_auto_env(server).await?; wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture.submit_text_turn("call the MCP echo tool").await?; - fixture - .submit_turn_with_permission_profile( - "call the rmcp echo tool with a very large message", - PermissionProfile::read_only(), - ) - .await?; - - let output = mock2 + let output = response .single_request() .function_call_output_text(call_id) - .context("function_call_output present for rmcp call")?; + .context("model-facing MCP output text")?; + Ok((fixture, output)) +} +#[test_case(3_000, 13_000; "serialization allowance")] +#[test_case(30_000, 116_000; "large override")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_output_limit_preserves_output_that_fits( + output_token_limit: usize, + message_bytes: usize, +) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); + + let server = start_mock_server().await; + let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50)); + let (_fixture, output) = + call_mcp_echo(&server, builder, Some(output_token_limit), message_bytes).await?; + + assert!(output.contains(&"a".repeat(message_bytes))); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_output_limit_truncates_oversized_output() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); + + let server = start_mock_server().await; + let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50)); + let (_fixture, output) = call_mcp_echo( + &server, + builder, + Some(30_000), + /*message_bytes*/ 150_000, + ) + .await?; + + assert!(output.contains("truncated")); + // 30k tokens plus the serialization allowance leaves about 144k bytes. + assert!((140_000..145_000).contains(&output.len())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_output_limit_applies_to_hook_feedback() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); + + let server = start_mock_server().await; + let builder = test_codex() + .with_pre_build_hook(|home| { + super::hooks_mcp::write_mcp_tool_hook( + home, + "PostToolUse", + Some("^mcp__rmcp__echo$"), + "rmcp", + &json!({ "continue": false, "stopReason": "hook feedback ".repeat(100) }) + .to_string(), + ) + .expect("write MCP post-tool hook"); + }) + .with_config(|config| { + core_test_support::hooks::trust_discovered_hooks(config); + config.tool_output_token_limit = Some(50); + }); + let (_fixture, output) = + call_mcp_echo(&server, builder, Some(100), /*message_bytes*/ 0).await?; + + assert!(output.starts_with("hook feedback ")); + assert!(output.contains("truncated")); + // The tool's 120-token budget applies, not the 60-token global budget. + assert!((400..600).contains(&output.len())); + Ok(()) +} + +#[test_case(None; "model default")] +#[test_case(Some(30_000); "tool override")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_output_limit_survives_resume(output_token_limit: Option) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); + + let server = start_mock_server().await; + let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50_000)); + let (fixture, output) = call_mcp_echo( + &server, + builder, + output_token_limit, + /*message_bytes*/ 150_000, + ) + .await?; + + fixture.codex.ensure_rollout_materialized().await; + fixture.codex.flush_rollout().await?; + let resumed_response = mount_sse_once( + &server, + sse(vec![ + responses::ev_assistant_message("msg-2", "resumed"), + responses::ev_completed("resp-3"), + ]), + ) + .await; + let mut resume_builder = test_codex().with_config(|config| { + config.tool_output_token_limit = Some(50); + }); + let resumed = resume_builder.restart(&server, &fixture).await?; + resumed.submit_turn("continue").await?; assert_eq!( - output.len(), - 80065, - "MCP output should retain its serialized length plus wall-time header" - ); - assert!( - !output.contains("truncated"), - "output should not include truncation markers when limit is raised: {output}" + resumed_response + .single_request() + .function_call_output_text("rmcp-output") + .context("resumed MCP output")?, + output ); Ok(()) diff --git a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs index f37f3411ec..3ab8077d70 100644 --- a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs +++ b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs @@ -231,8 +231,8 @@ async fn high_level_discovery_matches_the_existing_plugin_provider() -> TestResu "enabled_tools": ["read", "deploy", "trusted", "package-only"], "disabled_tools": ["package-denied"], "tools": { - "read": {"approval_mode": "prompt"}, - "deploy": {"approval_mode": "approve"}, + "read": {"approval_mode": "prompt", "output_token_limit": 12000}, + "deploy": {"approval_mode": "approve", "output_token_limit": 4000}, "trusted": {"approval_mode": "approve"} } }, @@ -255,6 +255,10 @@ disabled_tools = ["write"] [plugins."selected-root".mcp_servers.first.tools.read] approval_mode = "approve" +output_token_limit = 8000 + +[plugins."selected-root".mcp_servers.first.tools.deploy] +output_token_limit = 9000 [plugins."selected-root".mcp_servers.first.tools.trusted] approval_mode = "approve" @@ -308,18 +312,21 @@ default_tools_approval_mode = "auto" "read".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Prompt), + output_token_limit: std::num::NonZeroUsize::new(8_000), }, ), ( "deploy".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Prompt), + output_token_limit: std::num::NonZeroUsize::new(4_000), }, ), ( "trusted".to_string(), McpServerToolConfig { approval_mode: Some(AppToolApproval::Approve), + ..Default::default() }, ), ]), diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 6e15c01bc8..0241394fcb 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -48,6 +48,11 @@ pub struct CodexHarnessMetadata { /// Whether a developer message was supplied by an app-server client. #[serde(default)] pub client_authored: bool, + + /// Overrides history's fallback truncation budget, including on resume. + /// Measured in tokens, with any tool-specific allowance already included. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_token_limit_override: Option, } impl ResponseItemEnvelope { diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 97b61cf511..cca1050cd7 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -72,6 +72,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> item: response_item.clone(), metadata: Some(CodexHarnessMetadata { client_authored: true, + fallback_token_limit_override: Some(20_000), }), }), }; @@ -84,7 +85,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> "ordinal": 7, "type": "response_item", "payload": response_item, - "metadata": { "client_authored": true }, + "metadata": { "client_authored": true, "fallback_token_limit_override": 20_000 }, }) ); assert_eq!(serialized["payload"].get("metadata"), None); @@ -97,6 +98,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> envelope.metadata, Some(CodexHarnessMetadata { client_authored: true, + fallback_token_limit_override: Some(20_000), }) ); Ok(()) @@ -184,6 +186,7 @@ fn compacted_replacement_history_stores_metadata_in_an_aligned_sidecar() -> Resu item: developer_message.clone(), metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }, ResponseItemEnvelope::new(compaction_item.clone()), @@ -216,6 +219,7 @@ fn compacted_replacement_history_stores_metadata_in_an_aligned_sidecar() -> Resu item: developer_message, metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }, ResponseItemEnvelope { @@ -277,6 +281,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> item: response_item.clone(), metadata: Some(CodexHarnessMetadata { client_authored: true, + ..Default::default() }), }; let response_line = serde_json::to_value(RolloutItem::ResponseItem(envelope.clone()))?; diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index a8cfa569d3..0aa22b626d 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -23,6 +23,12 @@ pub trait ToolOutput: Send { false } + /// Overrides history's fallback token limit after tool-specific truncation. + /// Include any serialization allowance; history uses this limit unchanged. + fn fallback_token_limit_override(&self) -> Option { + None + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; /// Returns the tool call id exposed to `PostToolUse` hooks for this output. @@ -67,6 +73,10 @@ where (**self).contains_external_context() } + fn fallback_token_limit_override(&self) -> Option { + (**self).fallback_token_limit_override() + } + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { (**self).to_response_item(call_id, payload) } diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs index fc7d43bfc2..e612457396 100644 --- a/codex-rs/utils/output-truncation/src/lib.rs +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -1,6 +1,8 @@ -//! Helpers for truncating tool and exec output using [`TruncationPolicy`](codex_protocol::protocol::TruncationPolicy). +//! Shared byte/token truncation for tool and exec output. +use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; pub use codex_utils_string::approx_bytes_for_tokens; pub use codex_utils_string::approx_token_count; pub use codex_utils_string::approx_tokens_from_byte_count; @@ -29,6 +31,24 @@ pub fn truncate_text(content: &str, policy: TruncationPolicy) -> String { } } +/// Applies the existing byte/token budget without changing success metadata or media ordering. +pub fn truncate_function_output_payload( + output: &mut FunctionCallOutputPayload, + policy: TruncationPolicy, + estimate_audio_token_count: impl Fn(&str) -> usize, +) { + match &mut output.body { + FunctionCallOutputBody::Text(text) => *text = truncate_text(text, policy), + FunctionCallOutputBody::ContentItems(items) => { + *items = truncate_function_output_items_with_policy( + items, + policy, + estimate_audio_token_count, + ); + } + } +} + pub fn formatted_truncate_text_content_items_with_policy( items: &[FunctionCallOutputContentItem], policy: TruncationPolicy,