Reuse MCP handlers across sampling steps (#37273)

## Why

MCP tool handlers and their Code Mode definitions are immutable for the lifetime
of a stable MCP binding, so rebuilding them for every sampling step repeats schema
construction unnecessarily.

## What changed

- Cache MCP handlers per session and reuse them while the same binding remains
  active, invalidating the cache when the binding changes.
- Lazily cache schema-free Code Mode definitions on MCP handlers while continuing
  to rebuild definitions for step-local tools whose specifications may change.
- Reapply current app enablement, tool policy, exposure, and plugin schema budgets
  whenever cached handlers are registered for a step.

## Testing

- Cover handler and Code Mode definition reuse, binding invalidation, changing app
  policy, stable continuation schemas, and step-local custom tool updates.

GitOrigin-RevId: 69c5011fb97c08580ac476e7fb2286b1e92e0359
This commit is contained in:
jif authored and copyberry committed 2026-08-06 13:37:07 +00:00
1 parent bfb6a6ea22
commit e1831db7c3
13 files changed
+439 -85

No files matched your search

+42 -8
View File
@@ -80,6 +80,8 @@ use std::fs;
use std::io::Cursor;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use test_case::test_case;
@@ -4175,7 +4177,10 @@ text(JSON.stringify({
Ok(())
}
struct NamespacedCustomTool;
struct NamespacedCustomTool {
generation: usize,
generations: Arc<AtomicUsize>,
}
impl ToolContributor for NamespacedCustomTool {
fn tools(
@@ -4183,7 +4188,19 @@ impl ToolContributor for NamespacedCustomTool {
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
vec![Arc::new(Self)]
Vec::new()
}
fn tools_for_step(
&self,
_session_store: &ExtensionData,
_thread_store: &ExtensionData,
_step_store: &ExtensionData,
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
vec![Arc::new(Self {
generation: self.generations.fetch_add(1, Ordering::Relaxed) + 1,
generations: Arc::clone(&self.generations),
})]
}
}
@@ -4198,7 +4215,7 @@ impl ToolExecutor<ToolCall> for NamespacedCustomTool {
description: "Editing tools.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Custom(FreeformTool {
name: "apply_patch".to_string(),
description: "Apply a raw editor patch.".to_string(),
description: format!("Apply a raw editor patch (step {}).", self.generation),
defer_loading: None,
format: FreeformToolFormat {
r#type: "grammar".to_string(),
@@ -4220,6 +4237,7 @@ impl ToolExecutor<ToolCall> for NamespacedCustomTool {
"namespace": call.tool_name.namespace,
"name": call.tool_name.name,
"input": input,
"generation": self.generation,
}))) as Box<dyn ToolOutput>)
})
}
@@ -4231,7 +4249,10 @@ async fn code_mode_exposes_and_dispatches_namespaced_custom_tools() -> Result<()
let server = responses::start_mock_server().await;
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
extensions.tool_contributor(Arc::new(NamespacedCustomTool));
extensions.tool_contributor(Arc::new(NamespacedCustomTool {
generation: 0,
generations: Arc::new(AtomicUsize::new(0)),
}));
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_extensions(Arc::new(extensions.build()))
@@ -4283,8 +4304,11 @@ text(JSON.stringify({
let declaration =
"declare const tools: { editor__apply_patch(input: string): Promise<unknown>; };";
let description =
format!("Apply a raw editor patch.\n\nexec tool declaration:\n```ts\n{declaration}\n```");
let description = |generation| {
format!(
"Apply a raw editor patch (step {generation}).\n\nexec tool declaration:\n```ts\n{declaration}\n```"
)
};
let first_body = requests[0].body_json();
let namespaced_custom_tool = namespace_child_tool(&first_body, "editor", "apply_patch")
.expect("namespaced custom tool should be included in the model request");
@@ -4293,7 +4317,7 @@ text(JSON.stringify({
&serde_json::json!({
"type": "custom",
"name": "apply_patch",
"description": description,
"description": description(1),
"format": {
"type": "grammar",
"syntax": "lark",
@@ -4301,6 +4325,14 @@ text(JSON.stringify({
},
})
);
let second_body = requests[1].body_json();
let second_description = description(2);
assert_eq!(
namespace_child_tool(&second_body, "editor", "apply_patch")
.and_then(|tool| tool.get("description"))
.and_then(Value::as_str),
Some(second_description.as_str())
);
let (direct_output, direct_success) =
custom_tool_output_body_and_success(&requests[1], "call-direct");
@@ -4314,6 +4346,7 @@ text(JSON.stringify({
"namespace": "editor",
"name": "apply_patch",
"input": "direct patch",
"generation": 1,
})
);
@@ -4327,11 +4360,12 @@ text(JSON.stringify({
exec_output,
serde_json::json!({
"name": "editor__apply_patch",
"description": format!("Editing tools.\n\n{description}"),
"description": format!("Editing tools.\n\n{second_description}"),
"result": {
"namespace": "editor",
"name": "apply_patch",
"input": "nested patch",
"generation": 2,
},
})
);
+12 -2
View File
@@ -222,8 +222,13 @@ async fn mcp_calls_stay_bound_to_each_thread() -> anyhow::Result<()> {
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = call_response.single_request();
assert!(request.tool_by_name(NAMESPACE, "echo").is_some());
let output = completion_response
.single_request()
let completion_request = completion_response.single_request();
assert_eq!(
request.body_json()["tools"],
completion_request.body_json()["tools"],
"MCP tool schemas must remain unchanged across a same-turn continuation"
);
let output = completion_request
.function_call_output_text(call_id)
.expect("MCP result should be returned to the model");
assert!(output.contains(&process));
@@ -502,6 +507,11 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow
.await?;
let expected_error = format!("MCP tool `{SERVER_NAME}/cwd` is not available to the model");
assert_eq!(cached_turn.await??, second_process);
assert_definition(
&cached_done_response,
&format!("Use the tools from {second_process}."),
&format!("Echo from {second_process}."),
);
let output = cached_done_response
.single_request()
.function_call_output_text(app_only_call_id)