mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Represent dynamic tools with explicit namespaces internally (#27365)
Follow-up to #27356. ## Stack note This PR changes Codex's internal dynamic-tool shape while leaving `thread/start` unchanged. App-server therefore converts the existing per-tool input into explicit functions and namespaces before passing it to core. [#27371](https://github.com/openai/codex/pull/27371) updates `thread/start` to use the same explicit shape and removes this temporary conversion. ## Why Dynamic tools repeat namespace metadata on every function. Core should keep one explicit namespace with its member tools so descriptions and membership stay consistent across sessions and runtime planning. ## What changed - Represent dynamic tools as top-level functions or explicit namespaces in protocol and session state. - Read old flat rollout metadata and write the canonical hierarchy. - Flatten namespace members only when registering callable tools. - Keep `thread/start.dynamicTools` flat for now and normalize it at the app-server boundary. New builds can read old rollout metadata. Older builds cannot read newly written hierarchical metadata. ## Test plan - `just test -p codex-app-server thread_start_normalizes_legacy_dynamic_tools_into_model_request` - `just test -p codex-protocol session_meta_normalizes_legacy_dynamic_tools` - `just test -p codex-core resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled` - `just test -p codex-core tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call` - `just test -p codex-core code_mode_can_call_hidden_dynamic_tools` - `just test -p codex-tools`
This commit is contained in:
committed by
GitHub
Unverified
parent
b3f6f70b68
commit
a292faae5a
@@ -377,7 +377,8 @@ use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::group_dynamic_tools_by_namespace;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1077,22 +1077,29 @@ impl ThreadRequestProcessor {
|
||||
.default_environment_selections(&config.cwd)
|
||||
});
|
||||
let dynamic_tools = dynamic_tools.unwrap_or_default();
|
||||
// Count callable tools before grouping changes the outer list length.
|
||||
let core_dynamic_tool_count = dynamic_tools.len();
|
||||
let core_dynamic_tools = if dynamic_tools.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?;
|
||||
dynamic_tools
|
||||
// Normalize the flat app-server input into core's function and namespace types.
|
||||
let tools = dynamic_tools
|
||||
.into_iter()
|
||||
.map(|tool| CoreDynamicToolSpec {
|
||||
namespace: tool.namespace,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
defer_loading: tool.defer_loading,
|
||||
.map(|tool| {
|
||||
(
|
||||
tool.namespace,
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
defer_loading: tool.defer_loading,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
group_dynamic_tools_by_namespace(tools)
|
||||
};
|
||||
let core_dynamic_tool_count = core_dynamic_tools.len();
|
||||
let mut thread_extension_init = ExtensionDataInit::new();
|
||||
if !selected_capability_roots.is_empty() {
|
||||
thread_extension_init.insert(selected_capability_roots);
|
||||
|
||||
@@ -42,9 +42,8 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Ensures dynamic tool specs are serialized into the model request payload.
|
||||
#[tokio::test]
|
||||
async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> {
|
||||
async fn thread_start_normalizes_legacy_dynamic_tools_into_model_request() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
|
||||
@@ -54,29 +53,45 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
// Use a minimal JSON schema so we can assert the tool payload round-trips.
|
||||
let input_schema = json!({
|
||||
let visible_schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
"ticket_id": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"required": ["ticket_id"],
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
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.
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
dynamic_tools: Some(vec![dynamic_tool.clone()]),
|
||||
..Default::default()
|
||||
})
|
||||
.send_raw_request(
|
||||
"thread/start",
|
||||
Some(json!({
|
||||
"dynamicTools": [
|
||||
{
|
||||
"name": "lookup_ticket",
|
||||
"description": "Look up a ticket",
|
||||
"inputSchema": visible_schema,
|
||||
},
|
||||
{
|
||||
"namespace": "legacy_app",
|
||||
"name": "lookup_status",
|
||||
"description": "Look up a ticket status",
|
||||
"inputSchema": visible_schema,
|
||||
"exposeToContext": true
|
||||
},
|
||||
{
|
||||
"namespace": "legacy_app",
|
||||
"name": "update_ticket",
|
||||
"description": "Update a ticket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"exposeToContext": false
|
||||
}
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -85,13 +100,12 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
// Start a turn so a model request is issued.
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
thread_id: thread.id,
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text: "Look up the ticket".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -103,26 +117,42 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
// Inspect the captured model request to assert the tool spec made it through.
|
||||
let bodies = responses_bodies(&server).await?;
|
||||
let body = bodies
|
||||
.first()
|
||||
.context("expected at least one responses request")?;
|
||||
let tool = find_tool(body, &dynamic_tool.name)
|
||||
.context("expected dynamic tool to be injected into request")?;
|
||||
|
||||
let function =
|
||||
find_tool(&bodies[0], "lookup_ticket").context("expected normalized legacy function")?;
|
||||
assert_eq!(
|
||||
tool.get("description"),
|
||||
Some(&Value::String(dynamic_tool.description.clone()))
|
||||
function,
|
||||
&json!({
|
||||
"type": "function",
|
||||
"name": "lookup_ticket",
|
||||
"description": "Look up a ticket",
|
||||
"strict": false,
|
||||
"parameters": visible_schema,
|
||||
})
|
||||
);
|
||||
let namespace =
|
||||
find_tool(&bodies[0], "legacy_app").context("expected normalized legacy namespace")?;
|
||||
assert_eq!(
|
||||
namespace,
|
||||
&json!({
|
||||
"type": "namespace",
|
||||
"name": "legacy_app",
|
||||
"description": "Tools in the legacy_app namespace.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup_status",
|
||||
"description": "Look up a ticket status",
|
||||
"strict": false,
|
||||
"parameters": visible_schema,
|
||||
}],
|
||||
})
|
||||
);
|
||||
assert_eq!(tool.get("parameters"), Some(&input_schema));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ pub use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
pub use codex_protocol::config_types::CollaborationModeMask;
|
||||
pub use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
pub use codex_protocol::config_types::WebSearchMode;
|
||||
pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
pub use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
pub use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
pub use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
pub use codex_protocol::error::Result as CodexResult;
|
||||
pub use codex_protocol::models::PermissionProfile;
|
||||
|
||||
@@ -11,8 +11,9 @@ use crate::tools::registry::ToolExecutor;
|
||||
use crate::tools::registry::ToolExposure;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::protocol::DynamicToolCallResponseEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -36,13 +37,34 @@ pub struct DynamicToolHandler {
|
||||
}
|
||||
|
||||
impl DynamicToolHandler {
|
||||
pub fn new(tool: &DynamicToolSpec) -> Option<Self> {
|
||||
let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
|
||||
pub fn new(tool: &DynamicToolFunctionSpec) -> Option<Self> {
|
||||
Self::from_parts(tool, /*namespace*/ None)
|
||||
}
|
||||
|
||||
pub fn new_in_namespace(
|
||||
namespace: &DynamicToolNamespaceSpec,
|
||||
tool: &DynamicToolFunctionSpec,
|
||||
) -> Option<Self> {
|
||||
Self::from_parts(tool, Some(namespace))
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
tool: &DynamicToolFunctionSpec,
|
||||
namespace: Option<&DynamicToolNamespaceSpec>,
|
||||
) -> Option<Self> {
|
||||
let tool_name = ToolName::new(
|
||||
namespace.map(|namespace| namespace.name.clone()),
|
||||
tool.name.clone(),
|
||||
);
|
||||
let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
|
||||
let spec = match tool.namespace.as_ref() {
|
||||
let spec = match namespace {
|
||||
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: namespace.clone(),
|
||||
description: default_namespace_description(namespace),
|
||||
name: namespace.name.clone(),
|
||||
description: if namespace.description.trim().is_empty() {
|
||||
default_namespace_description(&namespace.name)
|
||||
} else {
|
||||
namespace.description.clone()
|
||||
},
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
|
||||
}),
|
||||
None => ToolSpec::Function(output_tool),
|
||||
|
||||
@@ -144,7 +144,8 @@ mod tests {
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
@@ -154,8 +155,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mixed_search_results_coalesce_mcp_namespaces() {
|
||||
let dynamic_tools = [DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
let dynamic_namespace = DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Tools in the codex_app namespace.".to_string(),
|
||||
tools: Vec::new(),
|
||||
};
|
||||
let dynamic_tools = [DynamicToolFunctionSpec {
|
||||
name: "automation_update".to_string(),
|
||||
description: "Create, update, view, or delete recurring automations.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -182,7 +187,7 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
search_infos.extend(dynamic_tools.iter().map(|tool| {
|
||||
DynamicToolHandler::new(tool)
|
||||
DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool)
|
||||
.expect("dynamic tool should convert")
|
||||
.search_info()
|
||||
.expect("dynamic handler should return search info")
|
||||
|
||||
@@ -10,6 +10,9 @@ use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ResponsesApiTool;
|
||||
use codex_extension_api::ToolCall as ExtensionToolCall;
|
||||
use codex_extension_api::ToolExecutor;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -249,30 +252,32 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
|
||||
let (_, turn) = make_session_and_context().await;
|
||||
let hidden_tool = "hidden_dynamic_tool";
|
||||
let visible_tool = "visible_dynamic_tool";
|
||||
let dynamic_tools = vec![
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: hidden_tool.to_string(),
|
||||
description: "Hidden until discovered.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Codex app tools.".to_string(),
|
||||
tools: vec![
|
||||
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
||||
name: hidden_tool.to_string(),
|
||||
description: "Hidden until discovered.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: visible_tool.to_string(),
|
||||
description: "Visible immediately.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
||||
name: visible_tool.to_string(),
|
||||
description: "Visible immediately.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
},
|
||||
];
|
||||
],
|
||||
})];
|
||||
|
||||
let router = ToolRouter::from_turn_context(
|
||||
&turn,
|
||||
|
||||
@@ -59,6 +59,7 @@ use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
@@ -813,16 +814,34 @@ fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
}
|
||||
|
||||
fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
|
||||
for tool in context.dynamic_tools {
|
||||
let Some(handler) = DynamicToolHandler::new(tool) else {
|
||||
tracing::error!(
|
||||
"Failed to convert dynamic tool {:?} to OpenAI tool",
|
||||
tool.name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
planned_tools.add(handler);
|
||||
for spec in context.dynamic_tools {
|
||||
match spec {
|
||||
DynamicToolSpec::Function(tool) => {
|
||||
let Some(handler) = DynamicToolHandler::new(tool) else {
|
||||
tracing::error!(
|
||||
"Failed to convert dynamic tool {:?} to OpenAI tool",
|
||||
tool.name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
planned_tools.add(handler);
|
||||
}
|
||||
DynamicToolSpec::Namespace(namespace) => {
|
||||
for tool in &namespace.tools {
|
||||
let DynamicToolNamespaceTool::Function(tool) = tool;
|
||||
let Some(handler) = DynamicToolHandler::new_in_namespace(namespace, tool)
|
||||
else {
|
||||
tracing::error!(
|
||||
"Failed to convert dynamic tool {:?}.{:?} to OpenAI tool",
|
||||
namespace.name,
|
||||
tool.name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
planned_tools.add(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,8 +384,7 @@ fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
|
||||
}
|
||||
|
||||
fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec {
|
||||
DynamicToolSpec {
|
||||
namespace: namespace.map(str::to_string),
|
||||
let function = codex_protocol::dynamic_tools::DynamicToolFunctionSpec {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} dynamic tool"),
|
||||
input_schema: json!({
|
||||
@@ -394,6 +393,18 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading,
|
||||
};
|
||||
match namespace {
|
||||
Some(namespace) => {
|
||||
DynamicToolSpec::Namespace(codex_protocol::dynamic_tools::DynamicToolNamespaceSpec {
|
||||
name: namespace.to_string(),
|
||||
description: format!("{namespace} dynamic tools"),
|
||||
tools: vec![
|
||||
codex_protocol::dynamic_tools::DynamicToolNamespaceTool::Function(function),
|
||||
],
|
||||
})
|
||||
}
|
||||
None => DynamicToolSpec::Function(function),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -3286,6 +3289,7 @@ text(JSON.stringify(tool));
|
||||
serde_json::json!({
|
||||
"name": "mcp__rmcp__echo",
|
||||
"description": concat!(
|
||||
"Use these tools to exercise the rmcp test server.\n\n",
|
||||
"Echo back the provided message and include environment data.\n\n",
|
||||
"exec tool declaration:\n",
|
||||
"```ts\n",
|
||||
@@ -3312,20 +3316,25 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
|
||||
.thread_manager
|
||||
.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!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
}],
|
||||
vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Codex app tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "A hidden dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
)],
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
let mut test = base_test;
|
||||
@@ -3333,8 +3342,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 === "codex_app_hidden_dynamic_tool");
|
||||
const out = await tools.codex_app_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,
|
||||
@@ -3441,7 +3450,7 @@ text(
|
||||
)?;
|
||||
assert_eq!(
|
||||
parsed.get("name"),
|
||||
Some(&Value::String("codex_app_hidden_dynamic_tool".to_string()))
|
||||
Some(&Value::String("codex_app__hidden_dynamic_tool".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("out"),
|
||||
@@ -3452,9 +3461,10 @@ text(
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|description| {
|
||||
description.contains("A hidden dynamic tool.")
|
||||
description.contains("Codex app tools.")
|
||||
&& description.contains("A hidden dynamic tool.")
|
||||
&& description.contains("declare const tools:")
|
||||
&& description.contains("codex_app_hidden_dynamic_tool(args:")
|
||||
&& description.contains("codex_app__hidden_dynamic_tool(args:")
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3475,17 +3485,22 @@ async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> {
|
||||
.thread_manager
|
||||
.start_thread_with_tools(
|
||||
base_test.config.clone(),
|
||||
vec![DynamicToolSpec {
|
||||
namespace: Some("excluded".to_string()),
|
||||
name: "lookup".to_string(),
|
||||
description: "An excluded dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
}],
|
||||
vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "excluded".to_string(),
|
||||
description: "Excluded tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: "lookup".to_string(),
|
||||
description: "An excluded dynamic tool.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
},
|
||||
)],
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
let mut test = base_test;
|
||||
|
||||
@@ -8,6 +8,9 @@ use codex_core::compact::SUMMARY_PREFIX;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -1147,22 +1150,24 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tools = vec![
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: hidden_tool.to_string(),
|
||||
description: "Hidden until discovered.".to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
defer_loading: true,
|
||||
},
|
||||
DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: visible_tool.to_string(),
|
||||
description: "Visible immediately.".to_string(),
|
||||
input_schema,
|
||||
defer_loading: false,
|
||||
},
|
||||
];
|
||||
let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Codex app tools.".to_string(),
|
||||
tools: vec![
|
||||
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
||||
name: hidden_tool.to_string(),
|
||||
description: "Hidden until discovered.".to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
defer_loading: true,
|
||||
}),
|
||||
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
||||
name: visible_tool.to_string(),
|
||||
description: "Visible immediately.".to_string(),
|
||||
input_schema,
|
||||
defer_loading: false,
|
||||
}),
|
||||
],
|
||||
})];
|
||||
let new_thread = test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(test.config.clone(), dynamic_tools)
|
||||
@@ -1784,13 +1789,18 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some("codex_app".to_string()),
|
||||
name: tool_name.to_string(),
|
||||
description: tool_description,
|
||||
input_schema,
|
||||
defer_loading: true,
|
||||
};
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Codex app tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool_name.to_string(),
|
||||
description: tool_description,
|
||||
input_schema,
|
||||
defer_loading: true,
|
||||
},
|
||||
)],
|
||||
});
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
|
||||
@@ -7,6 +7,9 @@ use codex_config::types::McpServerTransportConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -916,13 +919,18 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
"required": ["mode"],
|
||||
"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(),
|
||||
defer_loading: true,
|
||||
};
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "codex_app".to_string(),
|
||||
description: "Automation tools.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool_name.to_string(),
|
||||
description: tool_description.to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
defer_loading: true,
|
||||
},
|
||||
)],
|
||||
});
|
||||
|
||||
let mut builder = test_codex().with_config(configure_search_capable_model);
|
||||
let base_test = builder.build(&server).await?;
|
||||
@@ -998,7 +1006,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
|
||||
vec![json!({
|
||||
"type": "namespace",
|
||||
"name": "codex_app",
|
||||
"description": "Tools in the codex_app namespace.",
|
||||
"description": "Automation tools.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
@@ -1535,21 +1543,27 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch
|
||||
)
|
||||
.await;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some("orbit_ops".to_string()),
|
||||
name: "quasar_ping_beacon".to_string(),
|
||||
description: "Trigger the saffron metronome workflow for reminder follow-ups.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chrono_spec": { "type": "string" },
|
||||
"targetThreadId": { "type": "string" },
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: "orbit_ops".to_string(),
|
||||
description: "Orbital reminder operations.".to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: "quasar_ping_beacon".to_string(),
|
||||
description: "Trigger the saffron metronome workflow for reminder follow-ups."
|
||||
.to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chrono_spec": { "type": "string" },
|
||||
"targetThreadId": { "type": "string" },
|
||||
},
|
||||
"required": ["chrono_spec"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
},
|
||||
"required": ["chrono_spec"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: true,
|
||||
};
|
||||
)],
|
||||
});
|
||||
|
||||
let mut builder = test_codex().with_config(configure_search_capable_model);
|
||||
let base_test = builder.build(&server).await?;
|
||||
|
||||
@@ -7,6 +7,9 @@ use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -117,18 +120,28 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
|
||||
)
|
||||
.await;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "resume_lookup".to_string(),
|
||||
description: "Look up a value after resume.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
};
|
||||
let namespace = "resume_tools";
|
||||
let namespace_description = "Tools available after resume.";
|
||||
let tool_name = "resume_lookup";
|
||||
let tool_description = "Look up a value after resume.";
|
||||
let input_schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: namespace.to_string(),
|
||||
description: namespace_description.to_string(),
|
||||
tools: vec![DynamicToolNamespaceTool::Function(
|
||||
DynamicToolFunctionSpec {
|
||||
name: tool_name.to_string(),
|
||||
description: tool_description.to_string(),
|
||||
input_schema: input_schema.clone(),
|
||||
defer_loading: false,
|
||||
},
|
||||
)],
|
||||
});
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
@@ -138,7 +151,7 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
|
||||
let base_test = builder.build(&server).await?;
|
||||
let started = base_test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool.clone()])
|
||||
.start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool])
|
||||
.await?;
|
||||
let rollout_path = started
|
||||
.session_configured
|
||||
@@ -182,17 +195,144 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res
|
||||
.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("resumed request tools");
|
||||
let restored_tool = tools
|
||||
let restored_namespace = tools
|
||||
.iter()
|
||||
.find(|tool| tool.get("name") == Some(&json!(dynamic_tool.name.as_str())))
|
||||
.expect("dynamic tool should be restored from rollout metadata");
|
||||
.find(|tool| tool.get("name") == Some(&json!(namespace)))
|
||||
.expect("dynamic tool namespace should be restored from rollout metadata");
|
||||
assert_eq!(
|
||||
restored_tool.get("description"),
|
||||
Some(&json!(dynamic_tool.description.as_str()))
|
||||
restored_namespace,
|
||||
&json!({
|
||||
"type": "namespace",
|
||||
"name": namespace,
|
||||
"description": namespace_description,
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
"description": tool_description,
|
||||
"strict": false,
|
||||
"parameters": input_schema,
|
||||
}],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_restores_legacy_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let namespace = "resume_tools";
|
||||
let tool_name = "resume_lookup";
|
||||
let tool_description = "Look up a value after resume.";
|
||||
let input_schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "query": { "type": "string" } },
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
});
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let base_test = builder.build(&server).await?;
|
||||
let started = base_test
|
||||
.thread_manager
|
||||
.start_thread_with_tools(base_test.config.clone(), Vec::new())
|
||||
.await?;
|
||||
let rollout_path = started
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
started
|
||||
.thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "persist this thread".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&started.thread, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
started.thread.submit(Op::Shutdown).await?;
|
||||
wait_for_event(&started.thread, |event| {
|
||||
matches!(event, EventMsg::ShutdownComplete)
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut rollout_lines = fs::read_to_string(&rollout_path)?
|
||||
.lines()
|
||||
.map(serde_json::from_str::<serde_json::Value>)
|
||||
.collect::<serde_json::Result<Vec<_>>>()?;
|
||||
rollout_lines.first_mut().expect("session metadata line")["payload"]["dynamic_tools"] = json!([{
|
||||
"namespace": namespace,
|
||||
"name": tool_name,
|
||||
"description": tool_description,
|
||||
"inputSchema": input_schema,
|
||||
"exposeToContext": true,
|
||||
}]);
|
||||
let rollout = rollout_lines
|
||||
.iter()
|
||||
.map(serde_json::to_string)
|
||||
.collect::<serde_json::Result<Vec<_>>>()?
|
||||
.join("\n");
|
||||
fs::write(&rollout_path, format!("{rollout}\n"))?;
|
||||
|
||||
let mut resume_builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let resumed = resume_builder
|
||||
.resume(&server, base_test.home.clone(), rollout_path)
|
||||
.await?;
|
||||
resumed.submit_turn("use the restored tool").await?;
|
||||
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let resumed_body = requests[1].body_json();
|
||||
let tools = resumed_body
|
||||
.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("resumed request tools");
|
||||
let restored_namespace = tools
|
||||
.iter()
|
||||
.find(|tool| tool.get("name") == Some(&json!(namespace)))
|
||||
.expect("dynamic tool namespace should be restored from rollout metadata");
|
||||
assert_eq!(
|
||||
restored_tool.get("parameters"),
|
||||
Some(&dynamic_tool.input_schema)
|
||||
restored_namespace,
|
||||
&json!({
|
||||
"type": "namespace",
|
||||
"name": namespace,
|
||||
"description": "Tools in the resume_tools namespace.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
"description": tool_description,
|
||||
"strict": false,
|
||||
"parameters": input_schema,
|
||||
}],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,21 +2,46 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::Error as _;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type", export_to = "v2/")]
|
||||
pub enum DynamicToolSpec {
|
||||
Function(DynamicToolFunctionSpec),
|
||||
Namespace(DynamicToolNamespaceSpec),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicToolSpec {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct DynamicToolFunctionSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: JsonValue,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub defer_loading: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct DynamicToolNamespaceSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub tools: Vec<DynamicToolNamespaceTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type", export_to = "v2/")]
|
||||
pub enum DynamicToolNamespaceTool {
|
||||
Function(DynamicToolFunctionSpec),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicToolCallRequest {
|
||||
@@ -47,9 +72,11 @@ pub enum DynamicToolCallOutputContentItem {
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
/// Former flat `SessionMeta` shape, including the old `exposeToContext` flag.
|
||||
/// Kept so new builds can resume sessions written before explicit namespaces.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicToolSpecDe {
|
||||
struct LegacyDynamicToolSpec {
|
||||
namespace: Option<String>,
|
||||
name: String,
|
||||
description: String,
|
||||
@@ -58,84 +85,84 @@ struct DynamicToolSpecDe {
|
||||
expose_to_context: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DynamicToolSpec {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let DynamicToolSpecDe {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading,
|
||||
expose_to_context,
|
||||
} = DynamicToolSpecDe::deserialize(deserializer)?;
|
||||
|
||||
Ok(Self {
|
||||
namespace,
|
||||
name,
|
||||
description,
|
||||
input_schema,
|
||||
defer_loading: defer_loading
|
||||
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
|
||||
})
|
||||
pub fn normalize_dynamic_tool_specs(
|
||||
values: Vec<JsonValue>,
|
||||
) -> Result<Vec<DynamicToolSpec>, serde_json::Error> {
|
||||
let has_legacy_format = values.iter().any(|value| {
|
||||
value.get("namespace").is_some()
|
||||
|| value.get("exposeToContext").is_some()
|
||||
|| value.get("type").is_none()
|
||||
});
|
||||
let has_canonical_namespace = values
|
||||
.iter()
|
||||
.any(|value| value.get("type").and_then(JsonValue::as_str) == Some("namespace"));
|
||||
if has_legacy_format && has_canonical_namespace {
|
||||
return Err(serde_json::Error::custom(
|
||||
"dynamic tools must use either canonical or legacy format consistently",
|
||||
));
|
||||
}
|
||||
if !has_legacy_format {
|
||||
return values.into_iter().map(serde_json::from_value).collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DynamicToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[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 {
|
||||
namespace: None,
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" }
|
||||
}
|
||||
let tools = values
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
let tool: LegacyDynamicToolSpec = serde_json::from_value(value)?;
|
||||
let function = DynamicToolFunctionSpec {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
defer_loading: tool.defer_loading.unwrap_or_else(|| {
|
||||
tool.expose_to_context
|
||||
.map(|visible| !visible)
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
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);
|
||||
}
|
||||
};
|
||||
Ok((tool.namespace, function))
|
||||
})
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
Ok(group_dynamic_tools_by_namespace(tools))
|
||||
}
|
||||
|
||||
pub fn group_dynamic_tools_by_namespace(
|
||||
tools: Vec<(Option<String>, DynamicToolFunctionSpec)>,
|
||||
) -> Vec<DynamicToolSpec> {
|
||||
let mut grouped_tools = Vec::with_capacity(tools.len());
|
||||
let mut namespace_indices = HashMap::<String, usize>::new();
|
||||
for (namespace, function) in tools {
|
||||
let Some(namespace) = namespace else {
|
||||
grouped_tools.push(DynamicToolSpec::Function(function));
|
||||
continue;
|
||||
};
|
||||
let function = DynamicToolNamespaceTool::Function(function);
|
||||
if let Some(index) = namespace_indices.get(&namespace).copied() {
|
||||
let DynamicToolSpec::Namespace(namespace) = &mut grouped_tools[index] else {
|
||||
unreachable!("namespace index must point to a namespace");
|
||||
};
|
||||
namespace.tools.push(function);
|
||||
continue;
|
||||
}
|
||||
namespace_indices.insert(namespace.clone(), grouped_tools.len());
|
||||
grouped_tools.push(DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
||||
name: namespace,
|
||||
description: String::new(),
|
||||
tools: vec![function],
|
||||
}));
|
||||
}
|
||||
grouped_tools
|
||||
}
|
||||
|
||||
pub fn deserialize_dynamic_tool_specs<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<DynamicToolSpec>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let Some(values) = Option::<Vec<JsonValue>>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
normalize_dynamic_tool_specs(values)
|
||||
.map(Some)
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
@@ -2872,7 +2872,11 @@ pub struct SessionMeta {
|
||||
/// but may be missing for older sessions. If not present, fall back to rendering the base_instructions
|
||||
/// from ModelsManager.
|
||||
pub base_instructions: Option<BaseInstructions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::dynamic_tools::deserialize_dynamic_tool_specs",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_mode: Option<String>,
|
||||
@@ -4122,6 +4126,59 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_meta_normalizes_legacy_dynamic_tools() -> Result<()> {
|
||||
let mut value = serde_json::to_value(SessionMeta::default())?;
|
||||
value["dynamic_tools"] = json!([
|
||||
{
|
||||
"namespace": "legacy_app",
|
||||
"name": "lookup_ticket",
|
||||
"description": "Look up a ticket",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"exposeToContext": false
|
||||
},
|
||||
{
|
||||
"namespace": "legacy_app",
|
||||
"name": "update_ticket",
|
||||
"description": "Update a ticket",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"deferLoading": false,
|
||||
"exposeToContext": false
|
||||
}
|
||||
]);
|
||||
|
||||
let meta: SessionMeta = serde_json::from_value(value)?;
|
||||
|
||||
assert_eq!(
|
||||
meta.dynamic_tools,
|
||||
Some(vec![DynamicToolSpec::Namespace(
|
||||
crate::dynamic_tools::DynamicToolNamespaceSpec {
|
||||
name: "legacy_app".to_string(),
|
||||
description: String::new(),
|
||||
tools: vec![
|
||||
crate::dynamic_tools::DynamicToolNamespaceTool::Function(
|
||||
crate::dynamic_tools::DynamicToolFunctionSpec {
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Look up a ticket".to_string(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
defer_loading: true,
|
||||
},
|
||||
),
|
||||
crate::dynamic_tools::DynamicToolNamespaceTool::Function(
|
||||
crate::dynamic_tools::DynamicToolFunctionSpec {
|
||||
name: "update_ticket".to_string(),
|
||||
description: "Update a ticket".to_string(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
defer_loading: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sorted_writable_roots(roots: Vec<WritableRoot>) -> Vec<(PathBuf, Vec<PathBuf>)> {
|
||||
let mut sorted_roots: Vec<(PathBuf, Vec<PathBuf>)> = roots
|
||||
.into_iter()
|
||||
|
||||
@@ -63,7 +63,19 @@ pub fn collect_code_mode_tool_definitions<'a>(
|
||||
) -> Vec<CodeModeToolDefinition> {
|
||||
let mut tool_definitions = specs
|
||||
.into_iter()
|
||||
.flat_map(code_mode_tool_definitions_for_spec)
|
||||
.flat_map(|spec| {
|
||||
let mut definitions = code_mode_tool_definitions_for_spec(spec);
|
||||
if let ToolSpec::Namespace(namespace) = spec {
|
||||
let namespace_description = namespace.description.trim();
|
||||
if !namespace_description.is_empty() {
|
||||
for definition in &mut definitions {
|
||||
definition.description =
|
||||
format!("{namespace_description}\n\n{}", definition.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
definitions
|
||||
})
|
||||
.filter(|definition| codex_code_mode::is_code_mode_nested_tool(&definition.name))
|
||||
.map(codex_code_mode::augment_tool_definition)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::ToolDefinition;
|
||||
use crate::parse_tool_input_schema;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
|
||||
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
|
||||
pub fn parse_dynamic_tool(
|
||||
tool: &DynamicToolFunctionSpec,
|
||||
) -> Result<ToolDefinition, serde_json::Error> {
|
||||
Ok(ToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use super::parse_dynamic_tool;
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolDefinition;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn parse_dynamic_tool_sanitizes_input_schema() {
|
||||
let tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
let tool = DynamicToolFunctionSpec {
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
@@ -39,8 +38,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
|
||||
|
||||
#[test]
|
||||
fn parse_dynamic_tool_preserves_defer_loading() {
|
||||
let tool = DynamicToolSpec {
|
||||
namespace: None,
|
||||
let tool = DynamicToolFunctionSpec {
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "Fetch a ticket".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::ToolDefinition;
|
||||
use crate::ToolName;
|
||||
use crate::parse_dynamic_tool;
|
||||
use crate::parse_mcp_tool;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -67,7 +67,7 @@ pub enum ResponsesApiNamespaceTool {
|
||||
}
|
||||
|
||||
pub fn dynamic_tool_to_responses_api_tool(
|
||||
tool: &DynamicToolSpec,
|
||||
tool: &DynamicToolFunctionSpec,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool(
|
||||
tool,
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::tool_definition_to_responses_api_tool;
|
||||
use crate::JsonSchema;
|
||||
use crate::ToolDefinition;
|
||||
use crate::ToolName;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -50,8 +50,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,
|
||||
let tool = DynamicToolFunctionSpec {
|
||||
name: "lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
input_schema: json!({
|
||||
|
||||
Reference in New Issue
Block a user