diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index 29aac1784c..3743e6eb52 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -651,14 +651,54 @@ pub fn sanitize_schema_for_google(value: &mut serde_json::Value) { "exclusiveMaximum", "const", "multipleOf", + "unevaluatedItems", + "unevaluatedProperties", + ]; + const SINGLE_SCHEMA_FIELDS: &[&str] = &[ + "items", + "additionalItems", + "contains", + "not", + "if", + "then", + "else", + "contentSchema", + ]; + const ARRAY_SCHEMA_FIELDS: &[&str] = &["oneOf", "anyOf", "allOf", "prefixItems"]; + const MAP_SCHEMA_FIELDS: &[&str] = &[ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", + "dependencies", ]; if let Some(obj) = value.as_object_mut() { for field in UNSUPPORTED { obj.remove(*field); } - for v in obj.values_mut() { - sanitize_schema_for_google(v); + + for &key in SINGLE_SCHEMA_FIELDS { + if let Some(schema) = obj.get_mut(key) { + sanitize_schema_for_google(schema); + } + } + + for &key in ARRAY_SCHEMA_FIELDS { + if let Some(schemas) = obj.get_mut(key).and_then(serde_json::Value::as_array_mut) { + for schema in schemas { + sanitize_schema_for_google(schema); + } + } + } + + for &key in MAP_SCHEMA_FIELDS { + if let Some(schemas) = obj.get_mut(key).and_then(serde_json::Value::as_object_mut) { + for schema in schemas.values_mut() { + sanitize_schema_for_google(schema); + } + } } } else if let Some(arr) = value.as_array_mut() { for v in arr.iter_mut() { @@ -834,4 +874,127 @@ mod tests { assert!(schema["properties"]["staticInputs"]["propertyNames"].is_null()); assert!(schema["properties"]["staticInputs"]["additionalProperties"].is_null()); } + + #[test] + fn sanitize_schema_for_google_preserves_keyword_property_names() { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "default": { + "type": "string", + "default": "fallback" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "const": { + "type": "string", + "const": "value" + } + }, + "required": ["const"] + } + }, + "required": ["default", "additionalProperties"] + }); + + sanitize_schema_for_google(&mut schema); + + assert!(schema["properties"]["default"].is_object()); + assert_eq!(schema["properties"]["default"]["type"], "string"); + assert!(schema["properties"]["default"]["default"].is_null()); + + assert!(schema["properties"]["additionalProperties"].is_object()); + assert!(schema["properties"]["additionalProperties"]["additionalProperties"].is_null()); + assert!(schema["properties"]["additionalProperties"]["properties"]["const"].is_object()); + assert!( + schema["properties"]["additionalProperties"]["properties"]["const"]["const"].is_null() + ); + assert_eq!( + schema["required"], + serde_json::json!(["default", "additionalProperties"]) + ); + } + + #[test] + fn sanitize_schema_for_google_recurses_through_schema_containers() { + let mut schema = serde_json::json!({ + "type": "object", + "not": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "type": "string", + "default": "fallback" + } + } + }, + "if": { + "type": "object", + "const": { "kind": "a" } + }, + "then": { + "type": "object", + "properties": { + "value": { + "type": "number", + "multipleOf": 2 + } + } + }, + "prefixItems": [ + { + "type": "string", + "default": "first" + } + ], + "contentSchema": { + "type": "object", + "default": {} + }, + "unevaluatedItems": { + "type": "string", + "default": "item" + }, + "unevaluatedProperties": false, + "patternProperties": { + "^x-": { + "type": "string", + "const": "fixed" + } + }, + "dependentSchemas": { + "credit_card": { + "type": "object", + "additionalProperties": false + } + }, + "dependencies": { + "billing_address": { + "type": "object", + "default": {} + } + } + }); + + sanitize_schema_for_google(&mut schema); + + assert!(schema["not"]["additionalProperties"].is_null()); + assert!(schema["not"]["properties"]["default"].is_object()); + assert!(schema["not"]["properties"]["default"]["default"].is_null()); + assert!(schema["if"]["const"].is_null()); + assert!(schema["then"]["properties"]["value"]["multipleOf"].is_null()); + assert!(schema["prefixItems"][0]["default"].is_null()); + assert!(schema["contentSchema"]["default"].is_null()); + assert!(schema["unevaluatedItems"].is_null()); + assert!(schema["unevaluatedProperties"].is_null()); + assert!(schema["patternProperties"]["^x-"].is_object()); + assert!(schema["patternProperties"]["^x-"]["const"].is_null()); + assert!(schema["dependentSchemas"]["credit_card"].is_object()); + assert!(schema["dependentSchemas"]["credit_card"]["additionalProperties"].is_null()); + assert!(schema["dependencies"]["billing_address"].is_object()); + assert!(schema["dependencies"]["billing_address"]["default"].is_null()); + } } diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 8306f7aa71..c64db5efe0 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -15,13 +15,11 @@ pub struct McpToolSource { pub resource_path: String, } -use crate::ai_providers::{empty_string_as_none, AIProvider}; -use windmill_common::{ - db::DB, - error::Error, - flow_status::AgentAction, - flows::FlowModule, +use crate::{ + ai_google::sanitize_schema_for_google, + ai_providers::{empty_string_as_none, AIProvider}, }; +use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; use windmill_types::s3::S3Object; @@ -802,51 +800,20 @@ impl OpenAPISchema { /// Sanitizes this schema for Google AI's API by removing unsupported fields. /// See https://github.com/windmill-labs/windmill/issues/7759 pub fn sanitize_for_google(&mut self) { - self.schema_url = None; - self.default = None; - self.exclusive_minimum = None; - self.exclusive_maximum = None; - self.r#const = None; - self.multiple_of = None; - - // Recursively sanitize nested schemas - if let Some(ref mut items) = self.items { - items.sanitize_for_google(); - } - - if let Some(ref mut properties) = self.properties { - for prop in properties.values_mut() { - prop.sanitize_for_google(); + let mut schema_value = match serde_json::to_value(&*self) { + Ok(value) => value, + Err(err) => { + tracing::error!("Failed to serialize OpenAPISchema for Google AI: {err}"); + return; } - } + }; - if let Some(ref mut one_of) = self.one_of { - for schema in one_of.iter_mut() { - schema.sanitize_for_google(); - } - } + sanitize_schema_for_google(&mut schema_value); - if let Some(ref mut any_of) = self.any_of { - for schema in any_of.iter_mut() { - schema.sanitize_for_google(); - } - } - - if let Some(ref mut all_of) = self.all_of { - for schema in all_of.iter_mut() { - schema.sanitize_for_google(); - } - } - - if let Some(ref mut defs) = self.defs { - for schema in defs.values_mut() { - schema.sanitize_for_google(); - } - } - - if let Some(ref mut definitions) = self.definitions { - for schema in definitions.values_mut() { - schema.sanitize_for_google(); + match serde_json::from_value(schema_value) { + Ok(schema) => *self = schema, + Err(err) => { + tracing::error!("Failed to deserialize sanitized Google AI OpenAPISchema: {err}"); } } } @@ -1610,6 +1577,43 @@ mod tests { assert!(schema.multiple_of.is_none(), "multipleOf should be removed"); } + #[test] + fn test_sanitize_for_google_removes_additional_properties() { + let nested = OpenAPISchema { + r#type: Some(SchemaType::Single("object".to_string())), + additional_properties: Some(AdditionalProperties::Bool(true)), + ..Default::default() + }; + + let mut schema = OpenAPISchema { + r#type: Some(SchemaType::Single("object".to_string())), + additional_properties: Some(AdditionalProperties::Schema(Box::new(OpenAPISchema { + r#type: Some(SchemaType::Single("string".to_string())), + default: Some(serde_json::json!("fallback")), + ..Default::default() + }))), + properties: Some( + vec![("nested".to_string(), Box::new(nested))] + .into_iter() + .collect(), + ), + ..Default::default() + }; + + schema.sanitize_for_google(); + + assert!( + schema.additional_properties.is_none(), + "root additionalProperties should be removed" + ); + + let nested = schema.properties.as_ref().unwrap().get("nested").unwrap(); + assert!( + nested.additional_properties.is_none(), + "nested additionalProperties should be removed" + ); + } + #[test] fn test_sanitize_for_google_removes_unsupported_fields_recursively() { let nested = OpenAPISchema { diff --git a/integration_tests/ai_agent_tests/test_tool_calling.py b/integration_tests/ai_agent_tests/test_tool_calling.py index 5205a57727..7ac559250a 100644 --- a/integration_tests/ai_agent_tests/test_tool_calling.py +++ b/integration_tests/ai_agent_tests/test_tool_calling.py @@ -11,7 +11,7 @@ Tests AI agent tool calling with different tool types: import pytest from .conftest import AIAgentTestClient, create_ai_agent_flow, create_rawscript_tool, create_script_tool -from .providers import ALL_PROVIDERS, ANTHROPIC, GOOGLE_AI, OPENAI +from .providers import ALL_PROVIDERS, ANTHROPIC, GOOGLE_AI, OPENAI, make_provider_input_transform def get_provider_ids(providers: list) -> list[str]: @@ -26,6 +26,15 @@ export function main(a: number, b: number): number { } """ +GOOGLE_AI_GEMINI_3 = { + "name": "google_ai_gemini_3", + "input_transform": make_provider_input_transform( + kind="googleai", + model="gemini-3-flash-preview", + resource_path="u/admin/googleai", + ), +} + class TestToolCalling: """Test AI agent tool calling with different tool types.""" @@ -110,6 +119,57 @@ class TestToolCalling: assert "23" in result_str, f"Expected '23' in result: {result}" print(f"Workspace script tool result from {provider_config['name']}: {result}") + def test_nested_ai_agent_tool_with_gemini_3( + self, + client: AIAgentTestClient, + setup_providers, + ): + """ + Test that a Gemini agent can call another Gemini AI agent as a tool. + """ + nested_ai_agent_tool = { + "id": "delegate_agent", + "summary": "delegate_agent", + "value": { + "tool_type": "flowmodule", + "type": "aiagent", + "input_transforms": { + "provider": GOOGLE_AI_GEMINI_3["input_transform"], + "system_prompt": { + "type": "static", + "value": "You are a concise arithmetic helper. Return only the numeric answer.", + }, + "user_message": {"type": "ai"}, + "output_type": {"type": "static", "value": "text"}, + }, + "tools": [], + }, + } + + flow_value = create_ai_agent_flow( + provider_input_transform=GOOGLE_AI_GEMINI_3["input_transform"], + system_prompt="You are a coordinator. Use delegate_agent for arithmetic before answering.", + tools=[nested_ai_agent_tool], + output_type="text", + ) + + result = client.run_preview_flow( + flow_value=flow_value, + args={"user_message": "Ask delegate_agent what 13 + 29 is, then tell me the result."}, + ) + + assert result is not None + result_str = str(result) + assert "42" in result_str, f"Expected '42' in result: {result}" + + messages = result.get("messages", []) + assert any( + tool_call.get("function", {}).get("name") == "delegate_agent" + for message in messages + for tool_call in message.get("tool_calls", []) + ), f"Expected delegate_agent tool call in messages: {messages}" + print(f"Nested AI agent tool result from {GOOGLE_AI_GEMINI_3['name']}: {result}") + @pytest.mark.parametrize( "provider_config", ALL_PROVIDERS,