mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
fix: strip additionalProperties from google schemas (#8964)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user