mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[tool search] support namespaced deferred dynamic tools (#18413)
Deferred dynamic tools need to round-trip a namespace so a tool returned by `tool_search` can be called through the same registry key that core uses for dispatch. This change adds namespace support for dynamic tool specs/calls, persists it through app-server thread state, and routes dynamic tool calls by full `ToolName` while still sending the app the leaf tool name. Deferred dynamic tools must provide a namespace; non-deferred dynamic tools may remain top-level. It also introduces `LoadableToolSpec` as the shared function-or-namespace Responses shape used by both `tool_search` output and dynamic tool registration, so dynamic tools use the same wrapping logic in both paths. Validation: - `cargo test -p codex-tools` - `cargo test -p codex-core tool_search` --------- Co-authored-by: Sayan Sisodiya <sayan@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
1dcea729d3
commit
dc1a8f2190
@@ -532,6 +532,12 @@
|
||||
"inputSchema": true,
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"callId": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -24,4 +30,4 @@
|
||||
],
|
||||
"title": "DynamicToolCallParams",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3343,6 +3343,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -434,6 +434,12 @@
|
||||
"callId": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+19
-1
@@ -1965,6 +1965,12 @@
|
||||
"callId": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7381,6 +7387,12 @@
|
||||
"inputSchema": true,
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -14085,6 +14097,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/v2/DynamicToolCallStatus"
|
||||
},
|
||||
@@ -16594,4 +16612,4 @@
|
||||
},
|
||||
"title": "CodexAppServerProtocol",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -3998,6 +3998,12 @@
|
||||
"inputSchema": true,
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11989,6 +11995,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -854,6 +854,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -854,6 +854,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1511,6 +1511,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
+6
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1511,6 +1511,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
"inputSchema": true,
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1511,6 +1511,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -1273,6 +1273,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,12 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/DynamicToolCallStatus"
|
||||
},
|
||||
|
||||
+1
-1
@@ -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 DynamicToolCallParams = { threadId: string, turnId: string, callId: string, tool: string, arguments: JsonValue, };
|
||||
export type DynamicToolCallParams = { threadId: string, turnId: string, callId: string, namespace: string | null, tool: string, arguments: JsonValue, };
|
||||
|
||||
@@ -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, deferLoading?: boolean, };
|
||||
export type DynamicToolSpec = { namespace?: string, name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
|
||||
|
||||
@@ -57,7 +57,7 @@ durationMs: number | null, } | { "type": "fileChange", id: string, changes: Arra
|
||||
/**
|
||||
* The duration of the MCP tool call in milliseconds.
|
||||
*/
|
||||
durationMs: number | null, } | { "type": "dynamicToolCall", id: string, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array<DynamicToolCallOutputContentItem> | null, success: boolean | null,
|
||||
durationMs: number | null, } | { "type": "dynamicToolCall", id: string, namespace: string | null, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array<DynamicToolCallOutputContentItem> | null, success: boolean | null,
|
||||
/**
|
||||
* The duration of the dynamic tool call in milliseconds.
|
||||
*/
|
||||
|
||||
@@ -470,6 +470,7 @@ impl ThreadHistoryBuilder {
|
||||
) {
|
||||
let item = ThreadItem::DynamicToolCall {
|
||||
id: payload.call_id.clone(),
|
||||
namespace: payload.namespace.clone(),
|
||||
tool: payload.tool.clone(),
|
||||
arguments: payload.arguments.clone(),
|
||||
status: DynamicToolCallStatus::InProgress,
|
||||
@@ -493,6 +494,7 @@ impl ThreadHistoryBuilder {
|
||||
let duration_ms = i64::try_from(payload.duration.as_millis()).ok();
|
||||
let item = ThreadItem::DynamicToolCall {
|
||||
id: payload.call_id.clone(),
|
||||
namespace: payload.namespace.clone(),
|
||||
tool: payload.tool.clone(),
|
||||
arguments: payload.arguments.clone(),
|
||||
status,
|
||||
@@ -1975,6 +1977,7 @@ mod tests {
|
||||
codex_protocol::dynamic_tools::DynamicToolCallRequest {
|
||||
call_id: "dyn-1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
namespace: Some("codex_app".into()),
|
||||
tool: "lookup_ticket".into(),
|
||||
arguments: serde_json::json!({"id":"ABC-123"}),
|
||||
},
|
||||
@@ -1982,6 +1985,7 @@ mod tests {
|
||||
EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
|
||||
call_id: "dyn-1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
namespace: Some("codex_app".into()),
|
||||
tool: "lookup_ticket".into(),
|
||||
arguments: serde_json::json!({"id":"ABC-123"}),
|
||||
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
|
||||
@@ -2004,6 +2008,7 @@ mod tests {
|
||||
turns[0].items[1],
|
||||
ThreadItem::DynamicToolCall {
|
||||
id: "dyn-1".into(),
|
||||
namespace: Some("codex_app".into()),
|
||||
tool: "lookup_ticket".into(),
|
||||
arguments: serde_json::json!({"id":"ABC-123"}),
|
||||
status: DynamicToolCallStatus::Completed,
|
||||
|
||||
@@ -613,6 +613,8 @@ pub struct ToolsV2 {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct DynamicToolSpec {
|
||||
#[ts(optional)]
|
||||
pub namespace: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
@@ -623,6 +625,7 @@ pub struct DynamicToolSpec {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicToolSpecDe {
|
||||
namespace: Option<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: JsonValue,
|
||||
@@ -636,6 +639,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
@@ -644,6 +648,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
@@ -4887,6 +4892,7 @@ pub enum ThreadItem {
|
||||
#[ts(rename_all = "camelCase")]
|
||||
DynamicToolCall {
|
||||
id: String,
|
||||
namespace: Option<String>,
|
||||
tool: String,
|
||||
arguments: JsonValue,
|
||||
status: DynamicToolCallStatus,
|
||||
@@ -6650,6 +6656,7 @@ pub struct DynamicToolCallParams {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub call_id: String,
|
||||
pub namespace: Option<String>,
|
||||
pub tool: String,
|
||||
pub arguments: JsonValue,
|
||||
}
|
||||
@@ -9303,6 +9310,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
actual,
|
||||
DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
|
||||
@@ -954,10 +954,12 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
if matches!(api_version, ApiVersion::V2) {
|
||||
let call_id = request.call_id;
|
||||
let turn_id = request.turn_id;
|
||||
let namespace = request.namespace;
|
||||
let tool = request.tool;
|
||||
let arguments = request.arguments;
|
||||
let item = ThreadItem::DynamicToolCall {
|
||||
id: call_id.clone(),
|
||||
namespace: namespace.clone(),
|
||||
tool: tool.clone(),
|
||||
arguments: arguments.clone(),
|
||||
status: DynamicToolCallStatus::InProgress,
|
||||
@@ -977,6 +979,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
namespace,
|
||||
tool: tool.clone(),
|
||||
arguments: arguments.clone(),
|
||||
};
|
||||
@@ -1015,6 +1018,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
let duration_ms = i64::try_from(response.duration.as_millis()).ok();
|
||||
let item = ThreadItem::DynamicToolCall {
|
||||
id: response.call_id,
|
||||
namespace: response.namespace,
|
||||
tool: response.tool,
|
||||
arguments: response.arguments,
|
||||
status,
|
||||
|
||||
@@ -2662,6 +2662,7 @@ impl CodexMessageProcessor {
|
||||
dynamic_tools
|
||||
.into_iter()
|
||||
.map(|tool| CoreDynamicToolSpec {
|
||||
namespace: tool.namespace,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
@@ -9476,9 +9477,37 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
|
||||
if name == "mcp" || name.starts_with("mcp__") {
|
||||
return Err(format!("dynamic tool name is reserved: {name}"));
|
||||
}
|
||||
if !seen.insert(name.to_string()) {
|
||||
let namespace = tool.namespace.as_deref().map(str::trim);
|
||||
if let Some(namespace) = namespace {
|
||||
if namespace.is_empty() {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace must not be empty for {name}"
|
||||
));
|
||||
}
|
||||
if Some(namespace) != tool.namespace.as_deref() {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
|
||||
));
|
||||
}
|
||||
if namespace == "mcp" || namespace.starts_with("mcp__") {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace is reserved for {name}: {namespace}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if !seen.insert((namespace, name)) {
|
||||
if let Some(namespace) = namespace {
|
||||
return Err(format!(
|
||||
"duplicate dynamic tool name in namespace {namespace}: {name}"
|
||||
));
|
||||
}
|
||||
return Err(format!("duplicate dynamic tool name: {name}"));
|
||||
}
|
||||
if tool.defer_loading && namespace.is_none() {
|
||||
return Err(format!(
|
||||
"deferred dynamic tool must include a namespace: {name}"
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) {
|
||||
return Err(format!(
|
||||
@@ -10514,6 +10543,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({"type": "null"}),
|
||||
@@ -10526,6 +10556,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
// Missing `type` is common; core sanitizes these to a supported schema.
|
||||
@@ -10538,6 +10569,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_dynamic_tools_accepts_nullable_field_schema() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -10553,6 +10585,102 @@ mod tests {
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() {
|
||||
let tools = vec![
|
||||
ApiDynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
ApiDynamicToolSpec {
|
||||
namespace: Some("other_app".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
|
||||
let tools = vec![
|
||||
ApiDynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
ApiDynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("duplicate name");
|
||||
assert!(err.contains("codex_app"), "unexpected error: {err}");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_empty_namespace() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some("".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("empty namespace");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
assert!(err.contains("namespace"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_reserved_namespace() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some("mcp__server__".to_string()),
|
||||
name: "my_tool".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("reserved namespace");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
assert!(err.contains("reserved"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_from_stored_thread_preserves_millisecond_precision() {
|
||||
let created_at =
|
||||
|
||||
@@ -1067,6 +1067,7 @@ mod tests {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-0".to_string(),
|
||||
namespace: None,
|
||||
tool: "tool".to_string(),
|
||||
arguments: json!({}),
|
||||
},
|
||||
@@ -1124,6 +1125,7 @@ mod tests {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-0".to_string(),
|
||||
namespace: None,
|
||||
tool: "tool".to_string(),
|
||||
arguments: json!({}),
|
||||
},
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "demo_tool".to_string(),
|
||||
description: "Demo dynamic tool".to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
@@ -137,6 +138,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "hidden_tool".to_string(),
|
||||
description: "Hidden dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -197,10 +199,51 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result<()> {
|
||||
let server = MockServer::start().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 {
|
||||
namespace: None,
|
||||
name: "hidden_tool".to_string(),
|
||||
description: "Hidden dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
dynamic_tools: Some(vec![dynamic_tool]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(error.error.message.contains("hidden_tool"));
|
||||
assert!(error.error.message.contains("namespace"));
|
||||
|
||||
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<()> {
|
||||
let call_id = "dyn-call-1";
|
||||
let tool_namespace = "codex_app";
|
||||
let tool_name = "demo_tool";
|
||||
let tool_args = json!({ "city": "Paris" });
|
||||
let tool_call_arguments = serde_json::to_string(&tool_args)?;
|
||||
@@ -209,7 +252,16 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
let responses = vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"namespace": tool_namespace,
|
||||
"name": tool_name,
|
||||
"arguments": tool_call_arguments,
|
||||
}
|
||||
}),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
create_final_assistant_message_sse_response("Done")?,
|
||||
@@ -223,6 +275,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some(tool_namespace.to_string()),
|
||||
name: tool_name.to_string(),
|
||||
description: "Demo dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -274,6 +327,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
assert_eq!(started.turn_id, turn_id.clone());
|
||||
let ThreadItem::DynamicToolCall {
|
||||
id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
status,
|
||||
@@ -285,6 +339,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
panic!("expected dynamic tool call item");
|
||||
};
|
||||
assert_eq!(id, call_id);
|
||||
assert_eq!(namespace.as_deref(), Some(tool_namespace));
|
||||
assert_eq!(tool, tool_name);
|
||||
assert_eq!(arguments, tool_args);
|
||||
assert_eq!(status, DynamicToolCallStatus::InProgress);
|
||||
@@ -307,6 +362,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
call_id: call_id.to_string(),
|
||||
namespace: Some(tool_namespace.to_string()),
|
||||
tool: tool_name.to_string(),
|
||||
arguments: tool_args.clone(),
|
||||
};
|
||||
@@ -327,6 +383,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
assert_eq!(completed.turn_id, turn_id);
|
||||
let ThreadItem::DynamicToolCall {
|
||||
id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
status,
|
||||
@@ -338,6 +395,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
panic!("expected dynamic tool call item");
|
||||
};
|
||||
assert_eq!(id, call_id);
|
||||
assert_eq!(namespace.as_deref(), Some(tool_namespace));
|
||||
assert_eq!(tool, tool_name);
|
||||
assert_eq!(arguments, tool_args);
|
||||
assert_eq!(status, DynamicToolCallStatus::Completed);
|
||||
@@ -392,6 +450,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: tool_name.to_string(),
|
||||
description: "Demo dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -455,6 +514,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
thread_id,
|
||||
turn_id: turn_id.clone(),
|
||||
call_id: call_id.to_string(),
|
||||
namespace: None,
|
||||
tool: tool_name.to_string(),
|
||||
arguments: tool_args,
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
dynamic_tools: Some(vec![DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: tool_name.to_string(),
|
||||
description: "Deterministic wait tool".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -194,6 +195,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: started.turn_id,
|
||||
call_id: call_id.to_string(),
|
||||
namespace: None,
|
||||
tool: tool_name.to_string(),
|
||||
arguments: tool_args,
|
||||
}
|
||||
|
||||
@@ -94,7 +94,9 @@ use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
|
||||
use codex_protocol::protocol::TurnDiffEvent;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
use codex_utils_stream_parser::AssistantTextStreamParser;
|
||||
@@ -979,7 +981,7 @@ pub(crate) fn build_prompt(
|
||||
.dynamic_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.defer_loading)
|
||||
.map(|tool| tool.name.as_str())
|
||||
.map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone()))
|
||||
.collect::<HashSet<_>>();
|
||||
let tools = if deferred_dynamic_tools.is_empty() {
|
||||
router.model_visible_specs()
|
||||
@@ -987,7 +989,7 @@ pub(crate) fn build_prompt(
|
||||
router
|
||||
.model_visible_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| !deferred_dynamic_tools.contains(spec.name()))
|
||||
.filter_map(|spec| filter_deferred_dynamic_tool_spec(spec, &deferred_dynamic_tools))
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -1001,6 +1003,35 @@ pub(crate) fn build_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_deferred_dynamic_tool_spec(
|
||||
spec: ToolSpec,
|
||||
deferred_dynamic_tools: &HashSet<ToolName>,
|
||||
) -> Option<ToolSpec> {
|
||||
match spec {
|
||||
ToolSpec::Function(tool) => {
|
||||
if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) {
|
||||
None
|
||||
} else {
|
||||
Some(ToolSpec::Function(tool))
|
||||
}
|
||||
}
|
||||
ToolSpec::Namespace(mut namespace) => {
|
||||
let namespace_name = namespace.name.clone();
|
||||
namespace.tools.retain(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains(
|
||||
&ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()),
|
||||
),
|
||||
});
|
||||
if namespace.tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ToolSpec::Namespace(namespace))
|
||||
}
|
||||
}
|
||||
spec => Some(spec),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
|
||||
@@ -15,8 +15,8 @@ use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_tools::LoadableToolSpec;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSearchOutputTool;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
@@ -186,7 +186,7 @@ impl McpToolOutput {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolSearchOutput {
|
||||
pub tools: Vec<ToolSearchOutputTool>,
|
||||
pub tools: Vec<LoadableToolSpec>,
|
||||
}
|
||||
|
||||
impl ToolOutput for ToolSearchOutput {
|
||||
|
||||
@@ -284,20 +284,18 @@ fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
|
||||
},
|
||||
};
|
||||
let response = ToolSearchOutput {
|
||||
tools: vec![ToolSearchOutputTool::Function(
|
||||
codex_tools::ResponsesApiTool {
|
||||
name: "create_event".to_string(),
|
||||
description: String::new(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: codex_tools::JsonSchema::object(
|
||||
/*properties*/ Default::default(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
),
|
||||
output_schema: None,
|
||||
},
|
||||
)],
|
||||
tools: vec![LoadableToolSpec::Function(codex_tools::ResponsesApiTool {
|
||||
name: "create_event".to_string(),
|
||||
description: String::new(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: codex_tools::JsonSchema::object(
|
||||
/*properties*/ Default::default(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
),
|
||||
output_schema: None,
|
||||
})],
|
||||
}
|
||||
.to_response_item("search-1", &payload);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::protocol::DynamicToolCallResponseEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_tools::ToolName;
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -50,14 +51,13 @@ impl ToolHandler for DynamicToolHandler {
|
||||
};
|
||||
|
||||
let args: Value = parse_arguments(&arguments)?;
|
||||
let response =
|
||||
request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name.display(), args)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"dynamic tool call was cancelled before receiving a response".to_string(),
|
||||
)
|
||||
})?;
|
||||
let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"dynamic tool call was cancelled before receiving a response".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let DynamicToolResponse {
|
||||
content_items,
|
||||
@@ -79,9 +79,11 @@ async fn request_dynamic_tool(
|
||||
session: &Session,
|
||||
turn_context: &TurnContext,
|
||||
call_id: String,
|
||||
tool: String,
|
||||
tool_name: ToolName,
|
||||
arguments: Value,
|
||||
) -> Option<DynamicToolResponse> {
|
||||
let namespace = tool_name.namespace;
|
||||
let tool = tool_name.name;
|
||||
let turn_id = turn_context.sub_id.clone();
|
||||
let (tx_response, rx_response) = oneshot::channel();
|
||||
let event_id = call_id.clone();
|
||||
@@ -103,6 +105,7 @@ async fn request_dynamic_tool(
|
||||
let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
|
||||
call_id: call_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
namespace: namespace.clone(),
|
||||
tool: tool.clone(),
|
||||
arguments: arguments.clone(),
|
||||
});
|
||||
@@ -113,6 +116,7 @@ async fn request_dynamic_tool(
|
||||
Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
content_items: response.content_items.clone(),
|
||||
@@ -123,6 +127,7 @@ async fn request_dynamic_tool(
|
||||
None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
content_items: Vec::new(),
|
||||
|
||||
@@ -9,9 +9,10 @@ use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngine;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_tools::LoadableToolSpec;
|
||||
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolSearchOutputTool;
|
||||
use codex_tools::coalesce_loadable_tool_specs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const COMPUTER_USE_MCP_SERVER_NAME: &str = "computer-use";
|
||||
@@ -93,7 +94,7 @@ impl ToolSearchHandler {
|
||||
query: &str,
|
||||
limit: usize,
|
||||
use_default_limit: bool,
|
||||
) -> Result<Vec<ToolSearchOutputTool>, FunctionCallError> {
|
||||
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
|
||||
let results = self.search_result_entries(query, limit, use_default_limit);
|
||||
self.search_output_tools(results)
|
||||
}
|
||||
@@ -135,34 +136,10 @@ impl ToolSearchHandler {
|
||||
fn search_output_tools<'a>(
|
||||
&self,
|
||||
results: impl IntoIterator<Item = &'a ToolSearchEntry>,
|
||||
) -> Result<Vec<ToolSearchOutputTool>, FunctionCallError> {
|
||||
let mut tools = Vec::new();
|
||||
// Preserve search order: group namespace children, emit standalone tools directly.
|
||||
for entry in results {
|
||||
match &entry.output {
|
||||
ToolSearchOutputTool::Function(tool) => {
|
||||
tools.push(ToolSearchOutputTool::Function(tool.clone()));
|
||||
}
|
||||
ToolSearchOutputTool::Namespace(namespace) => {
|
||||
if let Some(output) = tools.iter_mut().find_map(|tool| match tool {
|
||||
ToolSearchOutputTool::Namespace(output)
|
||||
if output.name == namespace.name =>
|
||||
{
|
||||
Some(output)
|
||||
}
|
||||
ToolSearchOutputTool::Namespace(_) | ToolSearchOutputTool::Function(_) => {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
output.tools.extend(namespace.tools.clone());
|
||||
} else {
|
||||
tools.push(ToolSearchOutputTool::Namespace(namespace.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
|
||||
Ok(coalesce_loadable_tool_specs(
|
||||
results.into_iter().map(|entry| entry.output.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +186,7 @@ mod tests {
|
||||
#[test]
|
||||
fn mixed_search_results_coalesce_mcp_namespaces() {
|
||||
let dynamic_tools = vec![DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -247,7 +225,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![
|
||||
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
LoadableToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__calendar__".to_string(),
|
||||
description: "Tools in the mcp__calendar__ namespace.".to_string(),
|
||||
tools: vec![
|
||||
@@ -277,21 +255,25 @@ mod tests {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
ToolSearchOutputTool::Function(ResponsesApiTool {
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: codex_tools::JsonSchema::object(
|
||||
std::collections::BTreeMap::from([(
|
||||
"mode".to_string(),
|
||||
codex_tools::JsonSchema::string(/*description*/ None),
|
||||
)]),
|
||||
Some(vec!["mode".to_string()]),
|
||||
Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
LoadableToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Tools in the codex_app namespace.".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: codex_tools::JsonSchema::object(
|
||||
std::collections::BTreeMap::from([(
|
||||
"mode".to_string(),
|
||||
codex_tools::JsonSchema::string(/*description*/ None),
|
||||
)]),
|
||||
Some(vec!["mode".to_string()]),
|
||||
Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1619,7 +1619,11 @@ impl JsReplManager {
|
||||
ResponsesApiNamespaceTool::Function(tool) => {
|
||||
let tool_name =
|
||||
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
|
||||
(tool_name.display() == req.tool_name).then_some(tool_name)
|
||||
let code_mode_name =
|
||||
codex_tools::code_mode_name_for_tool_name(&tool_name);
|
||||
(code_mode_name == req.tool_name
|
||||
|| tool_name.display() == req.tool_name)
|
||||
.then_some(tool_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1819,6 +1819,7 @@ async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> {
|
||||
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "inline_image".to_string(),
|
||||
description: "Returns inline text and image content.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -1918,6 +1919,7 @@ async fn js_repl_dynamic_tool_response_preserves_js_line_separator_text() -> any
|
||||
] {
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: tool_name.to_string(),
|
||||
description: description.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -1993,6 +1995,7 @@ async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
|
||||
|
||||
let (session, turn, rx_event) =
|
||||
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "A hidden dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -2012,7 +2015,7 @@ async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let code = r#"
|
||||
const out = await codex.tool("hidden_dynamic_tool", { city: "Paris" });
|
||||
const out = await codex.tool("codex_app_hidden_dynamic_tool", { city: "Paris" });
|
||||
console.log(JSON.stringify(out));
|
||||
"#;
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_tools::ToolSearchOutputTool;
|
||||
use codex_tools::LoadableToolSpec;
|
||||
use codex_tools::ToolSearchResultSource;
|
||||
use codex_tools::dynamic_tool_to_responses_api_tool;
|
||||
use codex_tools::tool_search_result_source_to_output_tool;
|
||||
use codex_tools::dynamic_tool_to_loadable_tool_spec;
|
||||
use codex_tools::tool_search_result_source_to_loadable_tool_spec;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ToolSearchEntry {
|
||||
pub(crate) search_text: String,
|
||||
pub(crate) output: ToolSearchOutputTool,
|
||||
pub(crate) output: LoadableToolSpec,
|
||||
pub(crate) limit_bucket: Option<String>,
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) fn build_tool_search_entries(
|
||||
}
|
||||
|
||||
let mut dynamic_tools = dynamic_tools.iter().collect::<Vec<_>>();
|
||||
dynamic_tools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
dynamic_tools.sort_by(|a, b| a.namespace.cmp(&b.namespace).then(a.name.cmp(&b.name)));
|
||||
for tool in dynamic_tools {
|
||||
match dynamic_tool_search_entry(tool) {
|
||||
Ok(entry) => entries.push(entry),
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn build_tool_search_entries(
|
||||
fn mcp_tool_search_entry(info: &ToolInfo) -> Result<ToolSearchEntry, serde_json::Error> {
|
||||
Ok(ToolSearchEntry {
|
||||
search_text: build_mcp_search_text(info),
|
||||
output: tool_search_result_source_to_output_tool(ToolSearchResultSource {
|
||||
output: tool_search_result_source_to_loadable_tool_spec(ToolSearchResultSource {
|
||||
server_name: info.server_name.as_str(),
|
||||
tool_namespace: info.callable_namespace.as_str(),
|
||||
tool_name: info.callable_name.as_str(),
|
||||
@@ -70,7 +70,7 @@ fn mcp_tool_search_entry(info: &ToolInfo) -> Result<ToolSearchEntry, serde_json:
|
||||
fn dynamic_tool_search_entry(tool: &DynamicToolSpec) -> Result<ToolSearchEntry, serde_json::Error> {
|
||||
Ok(ToolSearchEntry {
|
||||
search_text: build_dynamic_search_text(tool),
|
||||
output: ToolSearchOutputTool::Function(dynamic_tool_to_responses_api_tool(tool)?),
|
||||
output: dynamic_tool_to_loadable_tool_spec(tool)?,
|
||||
limit_bucket: None,
|
||||
})
|
||||
}
|
||||
@@ -135,6 +135,10 @@ fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String {
|
||||
tool.description.clone(),
|
||||
];
|
||||
|
||||
if let Some(namespace) = &tool.namespace {
|
||||
parts.push(namespace.clone());
|
||||
}
|
||||
|
||||
parts.extend(
|
||||
tool.input_schema
|
||||
.get("properties")
|
||||
|
||||
@@ -2556,6 +2556,7 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
.start_thread_with_tools(
|
||||
base_test.config.clone(),
|
||||
vec![DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "A hidden dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -2576,8 +2577,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
test.session_configured = new_thread.session_configured;
|
||||
|
||||
let code = r#"
|
||||
const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool");
|
||||
const out = await tools.hidden_dynamic_tool({ city: "Paris" });
|
||||
const tool = ALL_TOOLS.find(({ name }) => name === "codex_app_hidden_dynamic_tool");
|
||||
const out = await tools.codex_app_hidden_dynamic_tool({ city: "Paris" });
|
||||
text(
|
||||
JSON.stringify({
|
||||
name: tool?.name ?? null,
|
||||
@@ -2636,6 +2637,7 @@ text(
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(request.namespace.as_deref(), Some("codex_app"));
|
||||
assert_eq!(request.tool, "hidden_dynamic_tool");
|
||||
assert_eq!(request.arguments, serde_json::json!({ "city": "Paris" }));
|
||||
test.codex
|
||||
@@ -2669,7 +2671,7 @@ text(
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed.get("name"),
|
||||
Some(&Value::String("hidden_dynamic_tool".to_string()))
|
||||
Some(&Value::String("codex_app_hidden_dynamic_tool".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("out"),
|
||||
@@ -2682,7 +2684,7 @@ text(
|
||||
.is_some_and(|description| {
|
||||
description.contains("A hidden dynamic tool.")
|
||||
&& description.contains("declare const tools:")
|
||||
&& description.contains("hidden_dynamic_tool(args:")
|
||||
&& description.contains("codex_app_hidden_dynamic_tool(args:")
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -730,6 +730,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": dynamic_call_id,
|
||||
"namespace": "codex_app",
|
||||
"name": tool_name,
|
||||
"arguments": tool_call_arguments,
|
||||
}
|
||||
@@ -754,6 +755,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: tool_name.to_string(),
|
||||
description: tool_description.to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
@@ -793,6 +795,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
unreachable!("event guard guarantees DynamicToolCallRequest");
|
||||
};
|
||||
assert_eq!(request.call_id, dynamic_call_id);
|
||||
assert_eq!(request.namespace.as_deref(), Some("codex_app"));
|
||||
assert_eq!(request.tool, tool_name);
|
||||
assert_eq!(request.arguments, tool_args);
|
||||
|
||||
@@ -832,12 +835,17 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
"description": tool_description,
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": input_schema,
|
||||
"type": "namespace",
|
||||
"name": "codex_app",
|
||||
"description": "Tools in the codex_app namespace.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
"description": tool_description,
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": input_schema,
|
||||
}],
|
||||
})]
|
||||
);
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
|
||||
let dynamic_tools = vec![
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "geo_lookup".to_string(),
|
||||
description: "lookup a city".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -113,6 +114,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
defer_loading: true,
|
||||
},
|
||||
DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "weather_lookup".to_string(),
|
||||
description: "lookup weather".to_string(),
|
||||
input_schema: json!({
|
||||
|
||||
@@ -8,6 +8,8 @@ use ts_rs::TS;
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicToolSpec {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
@@ -20,6 +22,8 @@ pub struct DynamicToolSpec {
|
||||
pub struct DynamicToolCallRequest {
|
||||
pub call_id: String,
|
||||
pub turn_id: String,
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
pub tool: String,
|
||||
pub arguments: JsonValue,
|
||||
}
|
||||
@@ -44,6 +48,7 @@ pub enum DynamicToolCallOutputContentItem {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicToolSpecDe {
|
||||
namespace: Option<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: JsonValue,
|
||||
@@ -57,6 +62,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
@@ -65,6 +71,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
@@ -99,6 +106,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
actual,
|
||||
DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
|
||||
@@ -2441,6 +2441,9 @@ pub struct DynamicToolCallResponseEvent {
|
||||
pub call_id: String,
|
||||
/// Turn ID that this dynamic tool call belongs to.
|
||||
pub turn_id: String,
|
||||
/// Dynamic tool namespace, when one was provided.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// Dynamic tool name.
|
||||
pub tool: String,
|
||||
/// Dynamic tool call arguments.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE thread_dynamic_tools
|
||||
ADD COLUMN namespace TEXT;
|
||||
@@ -56,7 +56,7 @@ WHERE threads.id = ?
|
||||
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT name, description, input_schema, defer_loading
|
||||
SELECT namespace, name, description, input_schema, defer_loading
|
||||
FROM thread_dynamic_tools
|
||||
WHERE thread_id = ?
|
||||
ORDER BY position ASC
|
||||
@@ -73,6 +73,7 @@ ORDER BY position ASC
|
||||
let input_schema: String = row.try_get("input_schema")?;
|
||||
let input_schema = serde_json::from_str::<Value>(input_schema.as_str())?;
|
||||
tools.push(DynamicToolSpec {
|
||||
namespace: row.try_get("namespace")?,
|
||||
name: row.try_get("name")?,
|
||||
description: row.try_get("description")?,
|
||||
input_schema,
|
||||
@@ -778,16 +779,18 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
INSERT INTO thread_dynamic_tools (
|
||||
thread_id,
|
||||
position,
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(thread_id, position) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(thread_id.as_str())
|
||||
.bind(position)
|
||||
.bind(tool.namespace.as_deref())
|
||||
.bind(tool.name.as_str())
|
||||
.bind(tool.description.as_str())
|
||||
.bind(input_schema)
|
||||
|
||||
@@ -16,7 +16,7 @@ schema and Responses API tool primitives that no longer need to live in
|
||||
- `ResponsesApiTool`
|
||||
- `FreeformTool`
|
||||
- `FreeformToolFormat`
|
||||
- `ToolSearchOutputTool`
|
||||
- `LoadableToolSpec`
|
||||
- `ResponsesApiWebSearchFilters`
|
||||
- `ResponsesApiWebSearchUserLocation`
|
||||
- `ResponsesApiNamespace`
|
||||
@@ -35,6 +35,7 @@ schema and Responses API tool primitives that no longer need to live in
|
||||
- `create_tools_json_for_responses_api()`
|
||||
- `mcp_call_tool_result_output_schema()`
|
||||
- `tool_definition_to_responses_api_tool()`
|
||||
- `dynamic_tool_to_loadable_tool_spec()`
|
||||
- `dynamic_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_deferred_responses_api_tool()`
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
|
||||
let tool_name =
|
||||
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
|
||||
let definition = CodeModeToolDefinition {
|
||||
name: tool_name.display(),
|
||||
name: code_mode_name_for_tool_name(&tool_name),
|
||||
tool_name,
|
||||
description: tool.description.clone(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
@@ -208,7 +208,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
|
||||
ResponsesApiNamespaceTool::Function(tool) => {
|
||||
let tool_name = ToolName::namespaced(namespace.name.clone(), tool.name.clone());
|
||||
CodeModeToolDefinition {
|
||||
name: tool_name.display(),
|
||||
name: code_mode_name_for_tool_name(&tool_name),
|
||||
tool_name,
|
||||
description: tool.description.clone(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
@@ -225,6 +225,16 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code_mode_name_for_tool_name(tool_name: &ToolName) -> String {
|
||||
match tool_name.namespace.as_deref() {
|
||||
Some(namespace) if namespace.ends_with('_') || tool_name.name.starts_with('_') => {
|
||||
format!("{namespace}{}", tool_name.name)
|
||||
}
|
||||
Some(namespace) => format!("{namespace}_{}", tool_name.name),
|
||||
None => tool_name.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "code_mode_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,18 +3,12 @@ use crate::parse_tool_input_schema;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
|
||||
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
|
||||
let DynamicToolSpec {
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading,
|
||||
} = tool;
|
||||
Ok(ToolDefinition {
|
||||
name: name.clone(),
|
||||
description: description.clone(),
|
||||
input_schema: parse_tool_input_schema(input_schema)?,
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: parse_tool_input_schema(&tool.input_schema)?,
|
||||
output_schema: None,
|
||||
defer_loading: *defer_loading,
|
||||
defer_loading: tool.defer_loading,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
|
||||
#[test]
|
||||
fn parse_dynamic_tool_sanitizes_input_schema() {
|
||||
let tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -42,6 +43,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
|
||||
#[test]
|
||||
fn parse_dynamic_tool_preserves_defer_loading() {
|
||||
let tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
|
||||
@@ -44,6 +44,7 @@ pub use apply_patch_tool::ApplyPatchToolArgs;
|
||||
pub use apply_patch_tool::create_apply_patch_freeform_tool;
|
||||
pub use apply_patch_tool::create_apply_patch_json_tool;
|
||||
pub use code_mode::augment_tool_spec_for_code_mode;
|
||||
pub use code_mode::code_mode_name_for_tool_name;
|
||||
pub use code_mode::collect_code_mode_exec_prompt_tool_definitions;
|
||||
pub use code_mode::collect_code_mode_tool_definitions;
|
||||
pub use code_mode::create_code_mode_tool;
|
||||
@@ -82,11 +83,13 @@ pub use request_user_input_tool::request_user_input_tool_description;
|
||||
pub use request_user_input_tool::request_user_input_unavailable_message;
|
||||
pub use responses_api::FreeformTool;
|
||||
pub use responses_api::FreeformToolFormat;
|
||||
pub use responses_api::LoadableToolSpec;
|
||||
pub use responses_api::ResponsesApiNamespace;
|
||||
pub use responses_api::ResponsesApiNamespaceTool;
|
||||
pub use responses_api::ResponsesApiTool;
|
||||
pub use responses_api::ToolSearchOutputTool;
|
||||
pub use responses_api::coalesce_loadable_tool_specs;
|
||||
pub(crate) use responses_api::default_namespace_description;
|
||||
pub use responses_api::dynamic_tool_to_loadable_tool_spec;
|
||||
pub use responses_api::dynamic_tool_to_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_responses_api_tool;
|
||||
@@ -114,7 +117,7 @@ pub use tool_discovery::collect_tool_suggest_entries;
|
||||
pub use tool_discovery::create_tool_search_tool;
|
||||
pub use tool_discovery::create_tool_suggest_tool;
|
||||
pub use tool_discovery::filter_tool_suggest_discoverable_tools_for_client;
|
||||
pub use tool_discovery::tool_search_result_source_to_output_tool;
|
||||
pub use tool_discovery::tool_search_result_source_to_loadable_tool_spec;
|
||||
pub use tool_registry_plan::build_tool_registry_plan;
|
||||
pub use tool_registry_plan_types::ToolHandlerKind;
|
||||
pub use tool_registry_plan_types::ToolHandlerSpec;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct ResponsesApiTool {
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ToolSearchOutputTool {
|
||||
pub enum LoadableToolSpec {
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "function")]
|
||||
Function(ResponsesApiTool),
|
||||
@@ -74,6 +74,51 @@ pub fn dynamic_tool_to_responses_api_tool(
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn dynamic_tool_to_loadable_tool_spec(
|
||||
tool: &DynamicToolSpec,
|
||||
) -> Result<LoadableToolSpec, serde_json::Error> {
|
||||
let output_tool = dynamic_tool_to_responses_api_tool(tool)?;
|
||||
Ok(match tool.namespace.as_ref() {
|
||||
Some(namespace) => LoadableToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: namespace.clone(),
|
||||
// the user doesn't provide a description for dynamic tools, so we use the default
|
||||
description: default_namespace_description(namespace),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
|
||||
}),
|
||||
None => LoadableToolSpec::Function(output_tool),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn coalesce_loadable_tool_specs(
|
||||
specs: impl IntoIterator<Item = LoadableToolSpec>,
|
||||
) -> Vec<LoadableToolSpec> {
|
||||
let mut coalesced_specs = Vec::new();
|
||||
for spec in specs {
|
||||
match spec {
|
||||
LoadableToolSpec::Function(tool) => {
|
||||
coalesced_specs.push(LoadableToolSpec::Function(tool));
|
||||
}
|
||||
LoadableToolSpec::Namespace(mut namespace) => {
|
||||
if let Some(existing_namespace) =
|
||||
coalesced_specs.iter_mut().find_map(|spec| match spec {
|
||||
LoadableToolSpec::Namespace(existing_namespace)
|
||||
if existing_namespace.name == namespace.name =>
|
||||
{
|
||||
Some(existing_namespace)
|
||||
}
|
||||
LoadableToolSpec::Function(_) | LoadableToolSpec::Namespace(_) => None,
|
||||
})
|
||||
{
|
||||
existing_namespace.tools.append(&mut namespace.tools);
|
||||
} else {
|
||||
coalesced_specs.push(LoadableToolSpec::Namespace(namespace));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
coalesced_specs
|
||||
}
|
||||
|
||||
pub fn mcp_tool_to_responses_api_tool(
|
||||
tool_name: &ToolName,
|
||||
tool: &rmcp::model::Tool,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::LoadableToolSpec;
|
||||
use super::ResponsesApiNamespace;
|
||||
use super::ResponsesApiNamespaceTool;
|
||||
use super::ResponsesApiTool;
|
||||
use super::ToolSearchOutputTool;
|
||||
use super::dynamic_tool_to_responses_api_tool;
|
||||
use super::mcp_tool_to_deferred_responses_api_tool;
|
||||
use super::tool_definition_to_responses_api_tool;
|
||||
@@ -51,6 +51,7 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
|
||||
#[test]
|
||||
fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
|
||||
let tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
input_schema: json!({
|
||||
@@ -130,8 +131,8 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
|
||||
let namespace = ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
fn loadable_tool_spec_namespace_serializes_with_deferred_child_tools() {
|
||||
let namespace = LoadableToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__calendar".to_string(),
|
||||
description: "Plan events".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::LoadableToolSpec;
|
||||
use crate::ResponsesApiNamespace;
|
||||
use crate::ResponsesApiNamespaceTool;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolName;
|
||||
use crate::ToolSearchOutputTool;
|
||||
use crate::ToolSpec;
|
||||
use crate::default_namespace_description;
|
||||
use crate::mcp_tool_to_deferred_responses_api_tool;
|
||||
@@ -203,10 +203,10 @@ pub fn create_tool_search_tool(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_search_result_source_to_output_tool(
|
||||
pub fn tool_search_result_source_to_loadable_tool_spec(
|
||||
source: ToolSearchResultSource<'_>,
|
||||
) -> Result<ToolSearchOutputTool, serde_json::Error> {
|
||||
Ok(ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
) -> Result<LoadableToolSpec, serde_json::Error> {
|
||||
Ok(LoadableToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: source.tool_namespace.to_string(),
|
||||
description: tool_search_result_source_namespace_description(source),
|
||||
tools: vec![tool_search_result_source_to_namespace_tool(source)?],
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use crate::TOOL_SEARCH_TOOL_NAME;
|
||||
use crate::TOOL_SUGGEST_TOOL_NAME;
|
||||
use crate::ToolHandlerKind;
|
||||
use crate::ToolName;
|
||||
use crate::ToolRegistryPlan;
|
||||
use crate::ToolRegistryPlanParams;
|
||||
use crate::ToolSearchSource;
|
||||
@@ -16,6 +17,7 @@ use crate::ToolSpec;
|
||||
use crate::ToolsConfig;
|
||||
use crate::ViewImageToolOptions;
|
||||
use crate::WebSearchToolOptions;
|
||||
use crate::coalesce_loadable_tool_specs;
|
||||
use crate::collect_code_mode_exec_prompt_tool_definitions;
|
||||
use crate::collect_tool_search_source_infos;
|
||||
use crate::collect_tool_suggest_entries;
|
||||
@@ -57,7 +59,7 @@ use crate::create_wait_tool;
|
||||
use crate::create_web_search_tool;
|
||||
use crate::create_write_stdin_tool;
|
||||
use crate::default_namespace_description;
|
||||
use crate::dynamic_tool_to_responses_api_tool;
|
||||
use crate::dynamic_tool_to_loadable_tool_spec;
|
||||
use crate::mcp_tool_to_responses_api_tool;
|
||||
use crate::request_permissions_tool_description;
|
||||
use crate::request_user_input_tool_description;
|
||||
@@ -555,15 +557,13 @@ pub fn build_tool_registry_plan(
|
||||
}
|
||||
}
|
||||
|
||||
let mut dynamic_tool_specs = Vec::new();
|
||||
for tool in params.dynamic_tools {
|
||||
match dynamic_tool_to_responses_api_tool(tool) {
|
||||
Ok(converted_tool) => {
|
||||
plan.push_spec(
|
||||
ToolSpec::Function(converted_tool),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(tool.name.clone(), ToolHandlerKind::DynamicTool);
|
||||
match dynamic_tool_to_loadable_tool_spec(tool) {
|
||||
Ok(loadable_tool) => {
|
||||
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
|
||||
dynamic_tool_specs.push(loadable_tool);
|
||||
plan.register_handler(handler_name, ToolHandlerKind::DynamicTool);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
@@ -573,6 +573,13 @@ pub fn build_tool_registry_plan(
|
||||
}
|
||||
}
|
||||
}
|
||||
for spec in coalesce_loadable_tool_specs(dynamic_tool_specs) {
|
||||
plan.push_spec(
|
||||
spec.into(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
@@ -1423,23 +1423,36 @@ fn search_tool_registers_for_deferred_dynamic_tools() {
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": { "type": "string" },
|
||||
},
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
let dynamic_tools = vec![
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": { "type": "string" },
|
||||
},
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: "automation_list".to_string(),
|
||||
description: "List recurring automations.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
];
|
||||
|
||||
let (tools, handlers) = build_specs(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[dynamic_tool],
|
||||
&dynamic_tools,
|
||||
);
|
||||
|
||||
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
@@ -1447,13 +1460,35 @@ fn search_tool_registers_for_deferred_dynamic_tools() {
|
||||
panic!("expected tool_search tool");
|
||||
};
|
||||
assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread."));
|
||||
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "automation_update"]);
|
||||
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "codex_app"]);
|
||||
assert_eq!(
|
||||
tools
|
||||
.iter()
|
||||
.filter(|tool| tool.name() == "codex_app")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
namespace_function_names(&tools, "codex_app"),
|
||||
vec![
|
||||
"automation_update".to_string(),
|
||||
"automation_list".to_string()
|
||||
]
|
||||
);
|
||||
for tool_name in ["automation_update", "automation_list"] {
|
||||
let dynamic_tool = find_namespace_function_tool(&tools, "codex_app", tool_name);
|
||||
assert_eq!(dynamic_tool.defer_loading, Some(true));
|
||||
}
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(TOOL_SEARCH_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ToolSearch,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain("automation_update"),
|
||||
name: ToolName::namespaced("codex_app", "automation_update"),
|
||||
kind: ToolHandlerKind::DynamicTool,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::namespaced("codex_app", "automation_list"),
|
||||
kind: ToolHandlerKind::DynamicTool,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::FreeformTool;
|
||||
use crate::JsonSchema;
|
||||
use crate::LoadableToolSpec;
|
||||
use crate::ResponsesApiNamespace;
|
||||
use crate::ResponsesApiTool;
|
||||
use codex_protocol::config_types::WebSearchConfig;
|
||||
@@ -70,6 +71,15 @@ impl ToolSpec {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoadableToolSpec> for ToolSpec {
|
||||
fn from(value: LoadableToolSpec) -> Self {
|
||||
match value {
|
||||
LoadableToolSpec::Function(tool) => ToolSpec::Function(tool),
|
||||
LoadableToolSpec::Namespace(namespace) => ToolSpec::Namespace(namespace),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_local_shell_tool() -> ToolSpec {
|
||||
ToolSpec::LocalShell {}
|
||||
}
|
||||
|
||||
@@ -655,6 +655,7 @@ mod tests {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "tool-1".to_string(),
|
||||
namespace: None,
|
||||
tool: "tool".to_string(),
|
||||
arguments: json!({}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user