Compare commits

...

1 Commits

Author SHA1 Message Date
Channing Conger
160b7ce0d3 Add new visibility flag to DyanmicToolSpecs 2026-03-02 21:34:28 +00:00
14 changed files with 180 additions and 24 deletions

View File

@@ -344,6 +344,10 @@
"type": "string"
},
"inputSchema": true,
"modelVisible": {
"default": true,
"type": "boolean"
},
"name": {
"type": "string"
}

View File

@@ -9166,6 +9166,10 @@
"type": "string"
},
"inputSchema": true,
"modelVisible": {
"default": true,
"type": "boolean"
},
"name": {
"type": "string"
}

View File

@@ -49,6 +49,10 @@
"type": "string"
},
"inputSchema": true,
"modelVisible": {
"default": true,
"type": "boolean"
},
"name": {
"type": "string"
}

View File

@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, };
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, modelVisible: boolean, };

View File

@@ -383,6 +383,12 @@ pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default = "default_model_visible")]
pub model_visible: bool,
}
fn default_model_visible() -> bool {
true
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -4256,6 +4262,26 @@ mod tests {
assert_eq!(back_to_v2, v2_policy);
}
#[test]
fn dynamic_tool_spec_defaults_model_visible_when_omitted() {
let spec: DynamicToolSpec = serde_json::from_value(json!({
"name": "demo_tool",
"description": "Demo dynamic tool",
"inputSchema": { "type": "object" }
}))
.expect("dynamic tool spec should deserialize");
assert_eq!(
spec,
DynamicToolSpec {
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({ "type": "object" }),
model_visible: true,
}
);
}
#[test]
fn sandbox_policy_round_trips_workspace_write_read_only_access() {
let readable_root = test_absolute_path();

View File

@@ -736,6 +736,8 @@ When the client responds to `item/tool/requestUserInput`, the server emits `serv
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
Each `dynamicTools` entry may include `modelVisible`. When omitted, it defaults to `true`. Set `modelVisible: false` to keep the tool callable at runtime without advertising it in the model's `tools` list.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
```json

View File

@@ -2159,6 +2159,7 @@ impl CodexMessageProcessor {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
model_visible: tool.model_visible,
})
.collect()
};
@@ -7958,6 +7959,7 @@ mod tests {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
model_visible: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
@@ -7970,6 +7972,7 @@ mod tests {
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
model_visible: true,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}

View File

@@ -36,9 +36,9 @@ use wiremock::MockServer;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Ensures dynamic tool specs are serialized into the model request payload.
/// Ensures only model-visible dynamic tools are serialized into the model request payload.
#[tokio::test]
async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> {
async fn thread_start_only_injects_model_visible_dynamic_tools_into_model_requests() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
@@ -57,16 +57,26 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
"required": ["city"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
let visible_dynamic_tool = DynamicToolSpec {
name: "visible_demo_tool".to_string(),
description: "Visible dynamic tool".to_string(),
input_schema: input_schema.clone(),
model_visible: true,
};
let hidden_dynamic_tool = DynamicToolSpec {
name: "hidden_demo_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: input_schema.clone(),
model_visible: 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()]),
dynamic_tools: Some(vec![
visible_dynamic_tool.clone(),
hidden_dynamic_tool.clone(),
]),
..Default::default()
})
.await?;
@@ -106,19 +116,20 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
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 tool = find_tool(body, &visible_dynamic_tool.name)
.context("expected visible dynamic tool to be injected into request")?;
assert_eq!(
tool.get("description"),
Some(&Value::String(dynamic_tool.description.clone()))
Some(&Value::String(visible_dynamic_tool.description.clone()))
);
assert_eq!(tool.get("parameters"), Some(&input_schema));
assert!(find_tool(body, &hidden_dynamic_tool.name).is_none());
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
/// Exercises the full dynamic tool call path for a hidden dynamic tool.
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
let call_id = "dyn-call-1";
@@ -154,6 +165,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
"required": ["city"],
"additionalProperties": false,
}),
model_visible: false,
};
let thread_req = mcp
@@ -281,6 +293,10 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
.iter()
.find_map(|body| function_call_output_payload(body, call_id))
.context("expected function_call_output in follow-up request")?;
let initial_request_body = bodies
.first()
.context("expected initial responses request body")?;
assert!(find_tool(initial_request_body, tool_name).is_none());
let expected_payload = FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "dynamic-ok".to_string(),
@@ -326,6 +342,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
"required": ["city"],
"additionalProperties": false,
}),
model_visible: true,
};
let thread_req = mcp

View File

@@ -2446,6 +2446,7 @@ console.log(out.output?.body?.text ?? "");
"properties": {},
"additionalProperties": false
}),
model_visible: true,
}])
.await;
if !turn

View File

@@ -1865,16 +1865,19 @@ pub(crate) fn build_specs(
if !dynamic_tools.is_empty() {
for tool in dynamic_tools {
match dynamic_tool_to_openai_tool(tool) {
Ok(converted_tool) => {
builder.push_spec(ToolSpec::Function(converted_tool));
builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone());
}
Err(e) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}",
tool.name
);
builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone());
if tool.model_visible {
match dynamic_tool_to_openai_tool(tool) {
Ok(converted_tool) => {
builder.push_spec(ToolSpec::Function(converted_tool));
}
Err(e) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}",
tool.name
);
}
}
}
}
@@ -2798,6 +2801,58 @@ mod tests {
assert_eq!(mcp_names, expected);
}
#[test]
fn hidden_dynamic_tools_register_handlers_without_prompt_specs() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let dynamic_tools = vec![
DynamicToolSpec {
name: "visible_tool".to_string(),
description: "Visible dynamic tool".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
model_visible: true,
},
DynamicToolSpec {
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
model_visible: false,
},
];
let (tools, registry) = build_specs(&tools_config, None, None, &dynamic_tools).build();
assert!(
tools
.iter()
.any(|tool| tool_name(&tool.spec) == "visible_tool")
);
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == "hidden_tool")
);
assert!(registry.handler("visible_tool").is_some());
assert!(registry.handler("hidden_tool").is_some());
}
#[test]
fn search_tool_description_includes_only_codex_apps_connector_names() {
let config = test_config();

View File

@@ -94,6 +94,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
"required": ["city"],
"properties": { "city": { "type": "string" } }
}),
model_visible: true,
},
DynamicToolSpec {
name: "weather_lookup".to_string(),
@@ -103,6 +104,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
"required": ["zip"],
"properties": { "zip": { "type": "string" } }
}),
model_visible: false,
},
];
let dynamic_tools_for_hook = dynamic_tools.clone();

View File

@@ -4,12 +4,18 @@ use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
fn default_model_visible() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default = "default_model_visible")]
pub model_visible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
@@ -37,3 +43,30 @@ pub enum DynamicToolCallOutputContentItem {
#[serde(rename_all = "camelCase")]
InputImage { image_url: String },
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn dynamic_tool_spec_defaults_model_visible_when_omitted() {
let spec: DynamicToolSpec = serde_json::from_value(json!({
"name": "demo_tool",
"description": "Demo dynamic tool",
"inputSchema": { "type": "object" }
}))
.expect("dynamic tool spec should deserialize");
assert_eq!(
spec,
DynamicToolSpec {
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({ "type": "object" }),
model_visible: true,
}
);
}
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE thread_dynamic_tools
ADD COLUMN model_visible BOOLEAN NOT NULL DEFAULT 1;

View File

@@ -42,7 +42,7 @@ WHERE id = ?
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
let rows = sqlx::query(
r#"
SELECT name, description, input_schema
SELECT name, description, input_schema, model_visible
FROM thread_dynamic_tools
WHERE thread_id = ?
ORDER BY position ASC
@@ -62,6 +62,7 @@ ORDER BY position ASC
name: row.try_get("name")?,
description: row.try_get("description")?,
input_schema,
model_visible: row.try_get("model_visible")?,
});
}
Ok(Some(tools))
@@ -304,8 +305,9 @@ INSERT INTO thread_dynamic_tools (
position,
name,
description,
input_schema
) VALUES (?, ?, ?, ?, ?)
input_schema,
model_visible
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(thread_id, position) DO NOTHING
"#,
)
@@ -314,6 +316,7 @@ ON CONFLICT(thread_id, position) DO NOTHING
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
.bind(tool.model_visible)
.execute(&mut *tx)
.await?;
}