diff --git a/core/src/main/java/com/google/adk/models/Claude.java b/core/src/main/java/com/google/adk/models/Claude.java index d3f65a40c..fe8a4f2e5 100644 --- a/core/src/main/java/com/google/adk/models/Claude.java +++ b/core/src/main/java/com/google/adk/models/Claude.java @@ -39,6 +39,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -55,6 +56,28 @@ public class Claude extends BaseLlm { private static final Logger logger = LoggerFactory.getLogger(Claude.class); + + // JSON Schema keywords traversed by updateTypeString, grouped by the shape of their value. + // Keywords whose value is a map of named sub-schemas (e.g. "properties": {"a": {...}}). + private static final ImmutableList NESTED_SCHEMA_MAP_KEYWORDS = + ImmutableList.of("$defs", "defs", "dependentSchemas", "patternProperties", "properties"); + // Keywords whose value is a single sub-schema (e.g. "items": {...}). + private static final ImmutableList NESTED_SCHEMA_KEYWORDS = + ImmutableList.of( + "additionalProperties", + "additional_properties", + "contains", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedProperties"); + // Keywords whose value is a list of sub-schemas (e.g. "anyOf": [{...}, {...}]). + private static final ImmutableList NESTED_SCHEMA_LIST_KEYWORDS = + ImmutableList.of("allOf", "all_of", "anyOf", "any_of", "oneOf", "one_of", "prefixItems"); + private int maxTokens = 8192; private final AnthropicClient anthropicClient; @@ -195,61 +218,133 @@ private String serializeToJson(Object obj) { } } - private void updateTypeString(Map valueDict) { - if (valueDict == null) { + /** + * Recursively lowercases JSON Schema {@code type} keywords for Anthropic compatibility. + * + *

{@code type} is only lowercased when it is a plain string. JSON Schema also permits a union + * type array (e.g. {@code ["string", "null"]}), which MCP tools can emit; those are traversed + * rather than cast to {@code String}. All nested sub-schemas (properties, {@code $defs}, {@code + * anyOf}, {@code items}, ...) are visited so deeply-nested types are normalized too. + */ + @SuppressWarnings("unchecked") + private void updateTypeString(Object value) { + if (value instanceof List) { + for (Object item : (List) value) { + updateTypeString(item); + } return; } - if (valueDict.containsKey("type")) { - valueDict.put("type", ((String) valueDict.get("type")).toLowerCase()); + if (!(value instanceof Map)) { + return; } + Map valueDict = (Map) value; - if (valueDict.containsKey("items")) { - updateTypeString((Map) valueDict.get("items")); - - if (valueDict.get("items") instanceof Map - && ((Map) valueDict.get("items")).containsKey("properties")) { - Map properties = - (Map) ((Map) valueDict.get("items")).get("properties"); - if (properties != null) { - for (Object value : properties.values()) { - if (value instanceof Map) { - updateTypeString((Map) value); - } - } + Object schemaType = valueDict.get("type"); + if (schemaType instanceof String) { + valueDict.put("type", ((String) schemaType).toLowerCase(Locale.ROOT)); + } + + for (String dictKey : NESTED_SCHEMA_MAP_KEYWORDS) { + Object child = valueDict.get(dictKey); + if (child instanceof Map) { + for (Object childValue : ((Map) child).values()) { + updateTypeString(childValue); } } } + + for (String singleKey : NESTED_SCHEMA_KEYWORDS) { + updateTypeString(valueDict.get(singleKey)); + } + + for (String listKey : NESTED_SCHEMA_LIST_KEYWORDS) { + updateTypeString(valueDict.get(listKey)); + } } private Tool functionDeclarationToAnthropicTool(FunctionDeclaration functionDeclaration) { - Map> properties = new HashMap<>(); - if (functionDeclaration.parameters().isPresent() - && functionDeclaration.parameters().get().properties().isPresent()) { - functionDeclaration - .parameters() - .get() - .properties() - .get() - .forEach( - (key, schema) -> { - Map schemaMap = - JsonBaseModel.getMapper() - .convertValue(schema, new TypeReference>() {}); - updateTypeString(schemaMap); - properties.put(key, schemaMap); - }); + Map inputSchema; + if (functionDeclaration.parametersJsonSchema().isPresent()) { + // MCP tools populate parametersJsonSchema (a raw JSON Schema object) instead of the + // structured parameters() field. Pass the whole schema through -- as a mutable copy -- so + // keys such as $ref/$defs/additionalProperties are preserved rather than dropped, then + // lowercase any type strings for Anthropic compatibility. + inputSchema = + JsonBaseModel.getMapper() + .convertValue( + functionDeclaration.parametersJsonSchema().get(), + new TypeReference>() {}); + } else { + Map properties = new HashMap<>(); + List required = new ArrayList<>(); + if (functionDeclaration.parameters().isPresent() + && functionDeclaration.parameters().get().properties().isPresent()) { + functionDeclaration + .parameters() + .get() + .properties() + .get() + .forEach( + (key, schema) -> + properties.put( + key, + JsonBaseModel.getMapper() + .convertValue(schema, new TypeReference>() {}))); + functionDeclaration.parameters().get().required().ifPresent(required::addAll); + } + inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", properties); + if (!required.isEmpty()) { + inputSchema.put("required", required); + } } + updateTypeString(inputSchema); return Tool.builder() .name(functionDeclaration.name().orElseThrow()) .description(functionDeclaration.description().orElse("")) - .inputSchema( - Tool.InputSchema.builder() - .properties(com.anthropic.core.JsonValue.from(properties)) - .build()) + .inputSchema(toAnthropicInputSchema(inputSchema)) .build(); } + /** + * Builds an Anthropic {@link Tool.InputSchema} from a JSON Schema map, preserving every top-level + * keyword (e.g. {@code $defs}, {@code additionalProperties}) as an additional property so schemas + * that rely on {@code $ref}/{@code $defs} are not sent to Claude with dangling references. + */ + private Tool.InputSchema toAnthropicInputSchema(Map schema) { + Tool.InputSchema.Builder builder = Tool.InputSchema.builder(); + schema.forEach( + (key, value) -> { + switch (key) { + case "type": + // The Anthropic input schema type is always "object"; skip to avoid a duplicate key. + break; + case "properties": + builder.properties( + com.anthropic.core.JsonValue.from(value == null ? new HashMap<>() : value)); + break; + case "required": + if (value instanceof List) { + List required = new ArrayList<>(); + for (Object item : (List) value) { + if (item != null) { + required.add(item.toString()); + } + } + builder.required(required); + } else { + builder.putAdditionalProperty(key, com.anthropic.core.JsonValue.from(value)); + } + break; + default: + builder.putAdditionalProperty(key, com.anthropic.core.JsonValue.from(value)); + } + }); + return builder.build(); + } + private LlmResponse convertAnthropicResponseToLlmResponse(Message message) { LlmResponse.Builder responseBuilder = LlmResponse.builder(); List parts = new ArrayList<>(); diff --git a/core/src/test/java/com/google/adk/models/ClaudeTest.java b/core/src/test/java/com/google/adk/models/ClaudeTest.java index 9d58ec2c1..b2dd059fd 100644 --- a/core/src/test/java/com/google/adk/models/ClaudeTest.java +++ b/core/src/test/java/com/google/adk/models/ClaudeTest.java @@ -23,13 +23,19 @@ import static org.mockito.Mockito.when; import com.anthropic.client.AnthropicClient; +import com.anthropic.core.JsonValue; import com.anthropic.models.messages.ContentBlockParam; import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.Tool; import com.anthropic.models.messages.ToolResultBlockParam; import com.anthropic.models.messages.Usage; +import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.FunctionResponse; import com.google.genai.types.Part; +import com.google.genai.types.Schema; import java.lang.reflect.Method; import java.util.Collections; import java.util.Map; @@ -44,6 +50,7 @@ public final class ClaudeTest { private Claude claude; private Method partToAnthropicMessageBlockMethod; + private Method functionDeclarationToAnthropicToolMethod; @Before public void setUp() throws Exception { @@ -54,6 +61,17 @@ public void setUp() throws Exception { partToAnthropicMessageBlockMethod = Claude.class.getDeclaredMethod("partToAnthropicMessageBlock", Part.class); partToAnthropicMessageBlockMethod.setAccessible(true); + + functionDeclarationToAnthropicToolMethod = + Claude.class.getDeclaredMethod( + "functionDeclarationToAnthropicTool", FunctionDeclaration.class); + functionDeclarationToAnthropicToolMethod.setAccessible(true); + } + + @SuppressWarnings("unchecked") + private static Map inputSchemaProperties(Tool tool) { + JsonValue properties = (JsonValue) tool.inputSchema()._properties(); + return properties.convert(new TypeReference>() {}); } @Test @@ -107,4 +125,139 @@ public void testClaudeUsageMapping_ShouldFailWhenMappingIsMissing() throws Excep assertEquals( outputTokens, (long) result.usageMetadata().get().candidatesTokenCount().orElse(0)); } + + @Test + public void functionDeclarationToAnthropicTool_usesParameters() throws Exception { + FunctionDeclaration functionDeclaration = + FunctionDeclaration.builder() + .name("retrievesItemByItemNumber") + .description("Retrieves an item") + .parameters( + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of("itemNumber", Schema.builder().type("STRING").build())) + .required(ImmutableList.of("itemNumber")) + .build()) + .build(); + + Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration); + + Map properties = inputSchemaProperties(tool); + assertThat(properties).containsKey("itemNumber"); + // The genai type "STRING" is lowercased to the JSON Schema "string" for Claude. + assertThat(((Map) properties.get("itemNumber")).get("type")) + .isEqualTo("string"); + assertThat(tool.inputSchema().required()).hasValue(ImmutableList.of("itemNumber")); + } + + @Test + public void functionDeclarationToAnthropicTool_fallsBackToParametersJsonSchema() + throws Exception { + // MCP tools populate parametersJsonSchema instead of the structured parameters() field. + Map jsonSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "dataset", + ImmutableMap.of("type", "string", "description", "The dataset id"), + "project", + ImmutableMap.of("type", "string")), + "required", + ImmutableList.of("dataset")); + FunctionDeclaration functionDeclaration = + FunctionDeclaration.builder() + .name("get_dataset_info") + .description("Gets dataset info") + .parametersJsonSchema(jsonSchema) + .build(); + + Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration); + + Map properties = inputSchemaProperties(tool); + // Before the fix these properties were empty, so Claude could not invoke the MCP tool. + assertThat(properties).containsKey("dataset"); + assertThat(properties).containsKey("project"); + assertThat(((Map) properties.get("dataset")).get("type")).isEqualTo("string"); + assertThat(tool.inputSchema().required()).hasValue(ImmutableList.of("dataset")); + } + + @Test + public void functionDeclarationToAnthropicTool_noParameters_hasEmptyProperties() + throws Exception { + FunctionDeclaration functionDeclaration = + FunctionDeclaration.builder().name("no_args_tool").description("Takes no args").build(); + + Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration); + + Map properties = inputSchemaProperties(tool); + assertThat(properties).isEmpty(); + assertThat(tool.inputSchema().required()).isEmpty(); + } + + @Test + public void functionDeclarationToAnthropicTool_unionTypeArray_doesNotThrow() throws Exception { + // JSON Schema permits a union type array (e.g. ["string", "null"]), which MCP tools can emit. + // It must not be cast to String, which previously threw ClassCastException. + Map jsonSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "nickname", ImmutableMap.of("type", ImmutableList.of("string", "null")))); + FunctionDeclaration functionDeclaration = + FunctionDeclaration.builder() + .name("set_nickname") + .description("Sets a nickname") + .parametersJsonSchema(jsonSchema) + .build(); + + Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration); + + Map properties = inputSchemaProperties(tool); + assertThat(properties).containsKey("nickname"); + // The union type array is preserved rather than crashing on the String cast. + assertThat(((Map) properties.get("nickname")).get("type")) + .isEqualTo(ImmutableList.of("string", "null")); + } + + @Test + public void functionDeclarationToAnthropicTool_preservesRefsAndDefs() throws Exception { + // MCP tools may use $ref/$defs. These top-level keywords must survive so Claude does not + // receive dangling references. + Map jsonSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of("pet", ImmutableMap.of("$ref", "#/$defs/Pet")), + "$defs", + ImmutableMap.of( + "Pet", + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of("name", ImmutableMap.of("type", "string"))))); + FunctionDeclaration functionDeclaration = + FunctionDeclaration.builder() + .name("register_pet") + .description("Registers a pet") + .parametersJsonSchema(jsonSchema) + .build(); + + Tool tool = (Tool) functionDeclarationToAnthropicToolMethod.invoke(claude, functionDeclaration); + + // The $ref is kept on the property... + Map properties = inputSchemaProperties(tool); + assertThat(((Map) properties.get("pet")).get("$ref")).isEqualTo("#/$defs/Pet"); + // ...and the $defs block survives as a top-level keyword. + JsonValue defsValue = (JsonValue) tool.inputSchema()._additionalProperties().get("$defs"); + assertThat(defsValue).isNotNull(); + Map defs = defsValue.convert(new TypeReference>() {}); + assertThat(defs).containsKey("Pet"); + } }