dynamic tool calls: add param exposeToContext to optionally hide tool (#14501)

This extends dynamic_tool_calls to allow us to hide a tool from the
model context but still use it as part of the general tool calling
runtime (for ex from js_repl/code_mode)
This commit is contained in:
Channing Conger
2026-03-14 01:58:43 -07:00
committed by GitHub
parent e389091042
commit 70eddad6b0
16 changed files with 578 additions and 14 deletions
+3
View File
@@ -205,6 +205,7 @@ Start a fresh thread when you need a new Codex conversation.
{
"name": "lookup_ticket",
"description": "Fetch a ticket by id",
"deferLoading": true,
"inputSchema": {
"type": "object",
"properties": {
@@ -991,6 +992,8 @@ If the session approval policy uses `Granular` with `request_permissions: false`
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `js_repl`, while excluding it from the model-facing tool list sent on ordinary turns.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
```json
@@ -2007,6 +2007,7 @@ impl CodexMessageProcessor {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
})
.collect()
};
@@ -8185,6 +8186,7 @@ mod tests {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
@@ -8197,6 +8199,7 @@ mod tests {
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
defer_loading: false,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}
@@ -61,6 +61,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
defer_loading: false,
};
// Thread start injects dynamic tools into the thread's tool registry.
@@ -118,6 +119,78 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
Ok(())
}
#[tokio::test]
async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: true,
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool.clone()]),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let bodies = responses_bodies(&server).await?;
assert!(
bodies
.iter()
.all(|body| find_tool(body, &dynamic_tool.name).is_none()),
"hidden dynamic tool should not be sent to the model"
);
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
@@ -154,6 +227,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp
@@ -322,6 +396,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp