Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions codex-rs/app-server-protocol/schema/json/ClientRequest.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7050,6 +7050,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3651,6 +3651,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";

export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, };
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
86 changes: 85 additions & 1 deletion codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,13 +535,48 @@ pub struct ToolsV2 {
pub view_image: Option<bool>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub defer_loading: bool,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
name: String,
description: String,
input_schema: JsonValue,
defer_loading: Option<bool>,
expose_to_context: Option<bool>,
}

impl<'de> Deserialize<'de> for DynamicToolSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let DynamicToolSpecDe {
name,
description,
input_schema,
defer_loading,
expose_to_context,
} = DynamicToolSpecDe::deserialize(deserializer)?;

Ok(Self {
name,
description,
input_schema,
defer_loading: defer_loading
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
})
}
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
Expand Down Expand Up @@ -7655,6 +7690,55 @@ mod tests {
);
}

#[test]
fn dynamic_tool_spec_deserializes_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
},
"deferLoading": true,
});

let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");

assert_eq!(
actual,
DynamicToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "string" }
}
}),
defer_loading: true,
}
);
}

#[test]
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {}
},
"exposeToContext": false,
});

let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");

assert!(actual.defer_loading);
}

#[test]
fn thread_start_params_preserve_explicit_null_service_tier() {
let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null }))
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/app-server/src/codex_message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,7 @@ impl CodexMessageProcessor {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
})
.collect()
};
Expand Down Expand Up @@ -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}");
Expand All @@ -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");
}
Expand Down
75 changes: 75 additions & 0 deletions codex-rs/app-server/tests/suite/v2/dynamic_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6219,9 +6219,25 @@ fn build_prompt(
turn_context: &TurnContext,
base_instructions: BaseInstructions,
) -> Prompt {
let deferred_dynamic_tools = turn_context
.dynamic_tools
.iter()
.filter(|tool| tool.defer_loading)
.map(|tool| tool.name.as_str())
.collect::<HashSet<_>>();
let tools = if deferred_dynamic_tools.is_empty() {
router.model_visible_specs()
} else {
router
.model_visible_specs()
.into_iter()
.filter(|spec| !deferred_dynamic_tools.contains(spec.name()))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter deferred tools by origin, not by name

build_prompt removes every model-visible spec whose name matches a deferred dynamic tool. If a deferred dynamic tool shares a name with a built-in/MCP tool, this line drops the unrelated tool from the prompt as well. That can silently disable expected tool access for the model, exceeding the intended “hide this dynamic tool” behavior.

Useful? React with 👍 / 👎.

.collect()
};

Prompt {
input,
tools: router.model_visible_specs(),
tools,
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
personality: turn_context.personality,
Expand Down
Loading
Loading