use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::model_family::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::ApplyPatchToolType; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::registry::ToolRegistryBuilder; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; #[derive(Debug, Clone)] pub enum ConfigShellToolType { Default, Local, Streamable, } #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub plan_tool: bool, pub apply_patch_tool_type: Option, pub web_search_request: bool, pub include_view_image_tool: bool, pub experimental_unified_exec_tool: bool, } pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_family: &'a ModelFamily, pub(crate) include_plan_tool: bool, pub(crate) include_apply_patch_tool: bool, pub(crate) include_web_search_request: bool, pub(crate) use_streamable_shell_tool: bool, pub(crate) include_view_image_tool: bool, pub(crate) experimental_unified_exec_tool: bool, } impl ToolsConfig { pub fn new(params: &ToolsConfigParams) -> Self { let ToolsConfigParams { model_family, include_plan_tool, include_apply_patch_tool, include_web_search_request, use_streamable_shell_tool, include_view_image_tool, experimental_unified_exec_tool, } = params; let shell_type = if *use_streamable_shell_tool { ConfigShellToolType::Streamable } else if model_family.uses_local_shell_tool { ConfigShellToolType::Local } else { ConfigShellToolType::Default }; let apply_patch_tool_type = match model_family.apply_patch_tool_type { Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), None => { if *include_apply_patch_tool { Some(ApplyPatchToolType::Freeform) } else { None } } }; Self { shell_type, plan_tool: *include_plan_tool, apply_patch_tool_type, web_search_request: *include_web_search_request, include_view_image_tool: *include_view_image_tool, experimental_unified_exec_tool: *experimental_unified_exec_tool, } } } /// Generic JSON‑Schema subset needed for our tool definitions #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "lowercase")] pub(crate) enum JsonSchema { Boolean { #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, String { #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, /// MCP schema allows "number" | "integer" for Number #[serde(alias = "integer")] Number { #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, Array { items: Box, #[serde(skip_serializing_if = "Option::is_none")] description: Option, }, Object { properties: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] required: Option>, #[serde( rename = "additionalProperties", skip_serializing_if = "Option::is_none" )] additional_properties: Option, }, } /// Whether additional properties are allowed, and if so, any required schema #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] pub(crate) enum AdditionalProperties { Boolean(bool), Schema(Box), } impl From for AdditionalProperties { fn from(b: bool) -> Self { Self::Boolean(b) } } impl From for AdditionalProperties { fn from(s: JsonSchema) -> Self { Self::Schema(Box::new(s)) } } fn create_unified_exec_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "input".to_string(), JsonSchema::Array { items: Box::new(JsonSchema::String { description: None }), description: Some( "When no session_id is provided, treat the array as the command and arguments \ to launch. When session_id is set, concatenate the strings (in order) and write \ them to the session's stdin." .to_string(), ), }, ); properties.insert( "session_id".to_string(), JsonSchema::String { description: Some( "Identifier for an existing interactive session. If omitted, a new command \ is spawned." .to_string(), ), }, ); properties.insert( "timeout_ms".to_string(), JsonSchema::Number { description: Some( "Maximum time in milliseconds to wait for output after writing the input." .to_string(), ), }, ); ToolSpec::Function(ResponsesApiTool { name: "unified_exec".to_string(), description: "Runs a command in a PTY. Provide a session_id to reuse an existing interactive session.".to_string(), strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["input".to_string()]), additional_properties: Some(false.into()), }, }) } fn create_shell_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "command".to_string(), JsonSchema::Array { items: Box::new(JsonSchema::String { description: None }), description: Some("The command to execute".to_string()), }, ); properties.insert( "workdir".to_string(), JsonSchema::String { description: Some("The working directory to execute the command in".to_string()), }, ); properties.insert( "timeout_ms".to_string(), JsonSchema::Number { description: Some("The timeout for the command in milliseconds".to_string()), }, ); properties.insert( "with_escalated_permissions".to_string(), JsonSchema::Boolean { description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), }, ); properties.insert( "justification".to_string(), JsonSchema::String { description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), }, ); ToolSpec::Function(ResponsesApiTool { name: "shell".to_string(), description: "Runs a shell command and returns its output.".to_string(), strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["command".to_string()]), additional_properties: Some(false.into()), }, }) } fn create_view_image_tool() -> ToolSpec { // Support only local filesystem path. let mut properties = BTreeMap::new(); properties.insert( "path".to_string(), JsonSchema::String { description: Some("Local filesystem path to an image file".to_string()), }, ); ToolSpec::Function(ResponsesApiTool { name: "view_image".to_string(), description: "Attach a local image (by filesystem path) to the conversation context for this turn." .to_string(), strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["path".to_string()]), additional_properties: Some(false.into()), }, }) } fn create_read_file_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "file_path".to_string(), JsonSchema::String { description: Some("Absolute path to the file".to_string()), }, ); properties.insert( "offset".to_string(), JsonSchema::Number { description: Some( "The line number to start reading from. Must be 1 or greater.".to_string(), ), }, ); properties.insert( "limit".to_string(), JsonSchema::Number { description: Some("The maximum number of lines to return.".to_string()), }, ); ToolSpec::Function(ResponsesApiTool { name: "read_file".to_string(), description: "Reads a local file with 1-indexed line numbers and returns up to the requested number of lines." .to_string(), strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["file_path".to_string()]), additional_properties: Some(false.into()), }, }) } /// TODO(dylan): deprecate once we get rid of json tool #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { pub(crate) input: String, } /// Returns JSON values that are compatible with Function Calling in the /// Responses API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=responses pub fn create_tools_json_for_responses_api( tools: &[ToolSpec], ) -> crate::error::Result> { let mut tools_json = Vec::new(); for tool in tools { let json = serde_json::to_value(tool)?; tools_json.push(json); } Ok(tools_json) } /// Returns JSON values that are compatible with Function Calling in the /// Chat Completions API: /// https://platform.openai.com/docs/guides/function-calling?api-mode=chat pub(crate) fn create_tools_json_for_chat_completions_api( tools: &[ToolSpec], ) -> crate::error::Result> { // We start with the JSON for the Responses API and than rewrite it to match // the chat completions tool call format. let responses_api_tools_json = create_tools_json_for_responses_api(tools)?; let tools_json = responses_api_tools_json .into_iter() .filter_map(|mut tool| { if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) { return None; } if let Some(map) = tool.as_object_mut() { // Remove "type" field as it is not needed in chat completions. map.remove("type"); Some(json!({ "type": "function", "function": map, })) } else { None } }) .collect::>(); Ok(tools_json) } pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: mcp_types::Tool, ) -> Result { let mcp_types::Tool { description, mut input_schema, .. } = tool; // OpenAI models mandate the "properties" field in the schema. The Agents // SDK fixed this by inserting an empty object for "properties" if it is not // already present https://github.com/openai/openai-agents-python/issues/449 // so here we do the same. if input_schema.properties.is_none() { input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new())); } // Serialize to a raw JSON value so we can sanitize schemas coming from MCP // servers. Some servers omit the top-level or nested `type` in JSON // Schemas (e.g. using enum/anyOf), or use unsupported variants like // `integer`. Our internal JsonSchema is a small subset and requires // `type`, so we coerce/sanitize here for compatibility. let mut serialized_input_schema = serde_json::to_value(input_schema)?; sanitize_json_schema(&mut serialized_input_schema); let input_schema = serde_json::from_value::(serialized_input_schema)?; Ok(ResponsesApiTool { name: fully_qualified_name, description: description.unwrap_or_default(), strict: false, parameters: input_schema, }) } /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// JsonSchema enum. This function: /// - Ensures every schema object has a "type". If missing, infers it from /// common keywords (properties => object, items => array, enum/const/format => string) /// and otherwise defaults to "string". /// - Fills required child fields (e.g. array items, object properties) with /// permissive defaults when absent. fn sanitize_json_schema(value: &mut JsonValue) { match value { JsonValue::Bool(_) => { // JSON Schema boolean form: true/false. Coerce to an accept-all string. *value = json!({ "type": "string" }); } JsonValue::Array(arr) => { for v in arr.iter_mut() { sanitize_json_schema(v); } } JsonValue::Object(map) => { // First, recursively sanitize known nested schema holders if let Some(props) = map.get_mut("properties") && let Some(props_map) = props.as_object_mut() { for (_k, v) in props_map.iter_mut() { sanitize_json_schema(v); } } if let Some(items) = map.get_mut("items") { sanitize_json_schema(items); } // Some schemas use oneOf/anyOf/allOf - sanitize their entries for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] { if let Some(v) = map.get_mut(combiner) { sanitize_json_schema(v); } } // Normalize/ensure type let mut ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string); // If type is an array (union), pick first supported; else leave to inference if ty.is_none() && let Some(JsonValue::Array(types)) = map.get("type") { for t in types { if let Some(tt) = t.as_str() && matches!( tt, "object" | "array" | "string" | "number" | "integer" | "boolean" ) { ty = Some(tt.to_string()); break; } } } // Infer type if still missing if ty.is_none() { if map.contains_key("properties") || map.contains_key("required") || map.contains_key("additionalProperties") { ty = Some("object".to_string()); } else if map.contains_key("items") || map.contains_key("prefixItems") { ty = Some("array".to_string()); } else if map.contains_key("enum") || map.contains_key("const") || map.contains_key("format") { ty = Some("string".to_string()); } else if map.contains_key("minimum") || map.contains_key("maximum") || map.contains_key("exclusiveMinimum") || map.contains_key("exclusiveMaximum") || map.contains_key("multipleOf") { ty = Some("number".to_string()); } } // If we still couldn't infer, default to string let ty = ty.unwrap_or_else(|| "string".to_string()); map.insert("type".to_string(), JsonValue::String(ty.to_string())); // Ensure object schemas have properties map if ty == "object" { if !map.contains_key("properties") { map.insert( "properties".to_string(), JsonValue::Object(serde_json::Map::new()), ); } // If additionalProperties is an object schema, sanitize it too. // Leave booleans as-is, since JSON Schema allows boolean here. if let Some(ap) = map.get_mut("additionalProperties") { let is_bool = matches!(ap, JsonValue::Bool(_)); if !is_bool { sanitize_json_schema(ap); } } } // Ensure array schemas have items if ty == "array" && !map.contains_key("items") { map.insert("items".to_string(), json!({ "type": "string" })); } } _ => {} } } /// Builds the tool registry builder while collecting tool specs for later serialization. pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, ) -> ToolRegistryBuilder { use crate::exec_command::EXEC_COMMAND_TOOL_NAME; use crate::exec_command::WRITE_STDIN_TOOL_NAME; use crate::exec_command::create_exec_command_tool_for_responses_api; use crate::exec_command::create_write_stdin_tool_for_responses_api; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::ExecStreamHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); let shell_handler = Arc::new(ShellHandler); let exec_stream_handler = Arc::new(ExecStreamHandler); let unified_exec_handler = Arc::new(UnifiedExecHandler); let plan_handler = Arc::new(PlanHandler); let read_file_handler = Arc::new(ReadFileHandler); let apply_patch_handler = Arc::new(ApplyPatchHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); if config.experimental_unified_exec_tool { builder.push_spec(create_unified_exec_tool()); builder.register_handler("unified_exec", unified_exec_handler); } else { match &config.shell_type { ConfigShellToolType::Default => { builder.push_spec(create_shell_tool()); } ConfigShellToolType::Local => { builder.push_spec(ToolSpec::LocalShell {}); } ConfigShellToolType::Streamable => { builder.push_spec(ToolSpec::Function( create_exec_command_tool_for_responses_api(), )); builder.push_spec(ToolSpec::Function( create_write_stdin_tool_for_responses_api(), )); builder.register_handler(EXEC_COMMAND_TOOL_NAME, exec_stream_handler.clone()); builder.register_handler(WRITE_STDIN_TOOL_NAME, exec_stream_handler); } } } // Always register shell aliases so older prompts remain compatible. builder.register_handler("shell", shell_handler.clone()); builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); if config.plan_tool { builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); } if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { builder.push_spec(create_apply_patch_freeform_tool()); } ApplyPatchToolType::Function => { builder.push_spec(create_apply_patch_json_tool()); } } builder.register_handler("apply_patch", apply_patch_handler); } builder.push_spec(create_read_file_tool()); builder.register_handler("read_file", read_file_handler); if config.web_search_request { builder.push_spec(ToolSpec::WebSearch {}); } if config.include_view_image_tool { builder.push_spec(create_view_image_tool()); builder.register_handler("view_image", view_image_handler); } if let Some(mcp_tools) = mcp_tools { let mut entries: Vec<(String, mcp_types::Tool)> = mcp_tools.into_iter().collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); for (name, tool) in entries.into_iter() { match mcp_tool_to_openai_tool(name.clone(), tool.clone()) { Ok(converted_tool) => { builder.push_spec(ToolSpec::Function(converted_tool)); builder.register_handler(name, mcp_handler.clone()); } Err(e) => { tracing::error!("Failed to convert {name:?} MCP tool to OpenAI tool: {e:?}"); } } } } builder } #[cfg(test)] mod tests { use crate::client_common::tools::FreeformTool; use crate::model_family::find_family_for_model; use mcp_types::ToolInputSchema; use pretty_assertions::assert_eq; use super::*; fn assert_eq_tool_names(tools: &[ToolSpec], expected_names: &[&str]) { let tool_names = tools .iter() .map(|tool| match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, ToolSpec::LocalShell {} => "local_shell", ToolSpec::WebSearch {} => "web_search", ToolSpec::Freeform(FreeformTool { name, .. }) => name, }) .collect::>(); assert_eq!( tool_names.len(), expected_names.len(), "tool_name mismatch, {tool_names:?}, {expected_names:?}", ); for (name, expected_name) in tool_names.iter().zip(expected_names.iter()) { assert_eq!( name, expected_name, "tool_name mismatch, {name:?}, {expected_name:?}" ); } } #[test] fn test_build_specs() { let model_family = find_family_for_model("codex-mini-latest") .expect("codex-mini-latest should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); assert_eq_tool_names( &tools, &[ "unified_exec", "update_plan", "read_file", "web_search", "view_image", ], ); } #[test] fn test_build_specs_default_shell() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: true, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); assert_eq_tool_names( &tools, &[ "unified_exec", "update_plan", "read_file", "web_search", "view_image", ], ); } #[test] fn test_build_specs_mcp_tools() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "test_server/do_something_cool".to_string(), mcp_types::Tool { name: "do_something_cool".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "string_argument": { "type": "string", }, "number_argument": { "type": "number", }, "object_argument": { "type": "object", "properties": { "string_property": { "type": "string" }, "number_property": { "type": "number" }, }, "required": [ "string_property", "number_property", ], "additionalProperties": Some(false), }, })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("Do something cool".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "test_server/do_something_cool", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([ ( "string_argument".to_string(), JsonSchema::String { description: None } ), ( "number_argument".to_string(), JsonSchema::Number { description: None } ), ( "object_argument".to_string(), JsonSchema::Object { properties: BTreeMap::from([ ( "string_property".to_string(), JsonSchema::String { description: None } ), ( "number_property".to_string(), JsonSchema::Number { description: None } ), ]), required: Some(vec![ "string_property".to_string(), "number_property".to_string(), ]), additional_properties: Some(false.into()), }, ), ]), required: None, additional_properties: None, }, description: "Do something cool".to_string(), strict: false, }) ); } #[test] fn test_build_specs_mcp_tools_sorted_by_name() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: false, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); // Intentionally construct a map with keys that would sort alphabetically. let tools_map: HashMap = HashMap::from([ ( "test_server/do".to_string(), mcp_types::Tool { name: "a".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({})), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("a".to_string()), }, ), ( "test_server/something".to_string(), mcp_types::Tool { name: "b".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({})), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("b".to_string()), }, ), ( "test_server/cool".to_string(), mcp_types::Tool { name: "c".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({})), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("c".to_string()), }, ), ]); let (tools, _) = build_specs(&config, Some(tools_map)).build(); // Expect unified_exec first, followed by MCP tools sorted by fully-qualified name. assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "view_image", "test_server/cool", "test_server/do", "test_server/something", ], ); } #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "dash/search".to_string(), mcp_types::Tool { name: "search".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "query": { "description": "search query" } })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("Search docs".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "dash/search", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "query".to_string(), JsonSchema::String { description: Some("search query".to_string()) } )]), required: None, additional_properties: None, }, description: "Search docs".to_string(), strict: false, }) ); } #[test] fn test_mcp_tool_integer_normalized_to_number() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "dash/paginate".to_string(), mcp_types::Tool { name: "paginate".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "page": { "type": "integer" } })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("Pagination".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "dash/paginate", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "page".to_string(), JsonSchema::Number { description: None } )]), required: None, additional_properties: None, }, description: "Pagination".to_string(), strict: false, }) ); } #[test] fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "dash/tags".to_string(), mcp_types::Tool { name: "tags".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "tags": { "type": "array" } })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("Tags".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "dash/tags", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "tags".to_string(), JsonSchema::Array { items: Box::new(JsonSchema::String { description: None }), description: None } )]), required: None, additional_properties: None, }, description: "Tags".to_string(), strict: false, }) ); } #[test] fn test_mcp_tool_anyof_defaults_to_string() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "dash/value".to_string(), mcp_types::Tool { name: "value".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] } })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("AnyOf Value".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "dash/value", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([( "value".to_string(), JsonSchema::String { description: None } )]), required: None, additional_properties: None, }, description: "AnyOf Value".to_string(), strict: false, }) ); } #[test] fn test_shell_tool() { let tool = super::create_shell_tool(); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool else { panic!("expected function tool"); }; assert_eq!(name, "shell"); let expected = "Runs a shell command and returns its output."; assert_eq!(description, expected); } #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, include_plan_tool: false, include_apply_patch_tool: false, include_web_search_request: true, use_streamable_shell_tool: false, include_view_image_tool: true, experimental_unified_exec_tool: true, }); let (tools, _) = build_specs( &config, Some(HashMap::from([( "test_server/do_something_cool".to_string(), mcp_types::Tool { name: "do_something_cool".to_string(), input_schema: ToolInputSchema { properties: Some(serde_json::json!({ "string_argument": { "type": "string", }, "number_argument": { "type": "number", }, "object_argument": { "type": "object", "properties": { "string_property": { "type": "string" }, "number_property": { "type": "number" }, }, "required": [ "string_property", "number_property", ], "additionalProperties": { "type": "object", "properties": { "addtl_prop": { "type": "string" }, }, "required": [ "addtl_prop", ], "additionalProperties": false, }, }, })), required: None, r#type: "object".to_string(), }, output_schema: None, title: None, annotations: None, description: Some("Do something cool".to_string()), }, )])), ) .build(); assert_eq_tool_names( &tools, &[ "unified_exec", "read_file", "web_search", "view_image", "test_server/do_something_cool", ], ); assert_eq!( tools[4], ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([ ( "string_argument".to_string(), JsonSchema::String { description: None } ), ( "number_argument".to_string(), JsonSchema::Number { description: None } ), ( "object_argument".to_string(), JsonSchema::Object { properties: BTreeMap::from([ ( "string_property".to_string(), JsonSchema::String { description: None } ), ( "number_property".to_string(), JsonSchema::Number { description: None } ), ]), required: Some(vec![ "string_property".to_string(), "number_property".to_string(), ]), additional_properties: Some( JsonSchema::Object { properties: BTreeMap::from([( "addtl_prop".to_string(), JsonSchema::String { description: None } ),]), required: Some(vec!["addtl_prop".to_string(),]), additional_properties: Some(false.into()), } .into() ), }, ), ]), required: None, additional_properties: None, }, description: "Do something cool".to_string(), strict: false, }) ); } }