From 297c78caecdd1a30b735d7842ee227aaf049499b Mon Sep 17 00:00:00 2001 From: YuqiGuo105 <131561736+YuqiGuo105@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:59:02 -0600 Subject: [PATCH] feat: support @Schema on FunctionTool object properties --- .../com/google/adk/tools/Annotations.java | 12 +- .../adk/tools/FunctionCallingUtils.java | 34 +- .../com/google/adk/tools/FunctionTool.java | 4 +- .../adk/tools/ToolSchemaObjectMapper.java | 69 ++++ .../adk/tools/FunctionToolSchemaTest.java | 357 ++++++++++++++++++ 5 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/com/google/adk/tools/ToolSchemaObjectMapper.java create mode 100644 core/src/test/java/com/google/adk/tools/FunctionToolSchemaTest.java diff --git a/core/src/main/java/com/google/adk/tools/Annotations.java b/core/src/main/java/com/google/adk/tools/Annotations.java index d2f3fbf2a..a25ce9a58 100644 --- a/core/src/main/java/com/google/adk/tools/Annotations.java +++ b/core/src/main/java/com/google/adk/tools/Annotations.java @@ -16,8 +16,10 @@ package com.google.adk.tools; +import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.RECORD_COMPONENT; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -26,14 +28,20 @@ /** Annotations for tools. */ public final class Annotations { - /** The annotation for binding the 'Schema' input. */ - @Target({METHOD, PARAMETER}) + /** + * Describes a tool or its inputs. On object properties, overrides Jackson's name and required + * flag for tool schemas and tool JSON binding only; ordinary JSON serialization is unchanged. + */ + @Target({METHOD, PARAMETER, FIELD, RECORD_COMPONENT}) @Retention(RetentionPolicy.RUNTIME) public @interface Schema { + /** Model-facing name; an empty property name retains Jackson's name or the Java default. */ String name() default ""; + /** Description included in the generated tool schema. */ String description() default ""; + /** Whether this input may be omitted from the generated schema's required list. */ boolean optional() default false; } diff --git a/core/src/main/java/com/google/adk/tools/FunctionCallingUtils.java b/core/src/main/java/com/google/adk/tools/FunctionCallingUtils.java index caf09dbe9..49538eafc 100644 --- a/core/src/main/java/com/google/adk/tools/FunctionCallingUtils.java +++ b/core/src/main/java/com/google/adk/tools/FunctionCallingUtils.java @@ -83,6 +83,12 @@ void addDefinition(JavaType type, Schema schema) { */ public static FunctionDeclaration buildFunctionDeclaration( Method func, List ignoreParams) { + return buildFunctionDeclaration( + func, ignoreParams, ToolSchemaObjectMapper.create(defaultObjectMapper)); + } + + static FunctionDeclaration buildFunctionDeclaration( + Method func, List ignoreParams, ObjectMapper objectMapper) { String name = func.isAnnotationPresent(Annotations.Schema.class) && !func.getAnnotation(Annotations.Schema.class).name().isEmpty() @@ -108,7 +114,7 @@ public static FunctionDeclaration buildFunctionDeclaration( if (schema == null || !schema.optional()) { required.add(paramName); } - properties.put(paramName, buildSchemaFromParameter(param)); + properties.put(paramName, buildSchemaFromParameter(param, objectMapper)); } builder.parameters( Schema.builder().required(required).properties(properties).type("OBJECT").build()); @@ -126,7 +132,11 @@ public static FunctionDeclaration buildFunctionDeclaration( actualReturnType = parameterizedReturnType.getActualTypeArguments()[0]; } } - builder.response(buildSchemaFromType(actualReturnType)); + builder.response( + buildSchemaRecursive( + objectMapper.constructType(actualReturnType), + new SchemaGenerationContext(), + objectMapper)); } return builder.build(); } @@ -143,8 +153,12 @@ static FunctionDeclaration buildFunctionDeclaration(JsonBaseModel func, String d return declaration; } - private static Schema buildSchemaFromParameter(Parameter param) { - Schema schema = buildSchemaFromType(param.getParameterizedType()); + private static Schema buildSchemaFromParameter(Parameter param, ObjectMapper objectMapper) { + Schema schema = + buildSchemaRecursive( + objectMapper.constructType(param.getParameterizedType()), + new SchemaGenerationContext(), + objectMapper); if (param.isAnnotationPresent(Annotations.Schema.class) && !param.getAnnotation(Annotations.Schema.class).description().isEmpty()) { return schema.toBuilder() @@ -174,8 +188,9 @@ public static Schema buildSchemaFromType(Type type) { * @throws IllegalArgumentException if a type is encountered that cannot be serialized by Jackson. */ public static Schema buildSchemaFromType(Type type, ObjectMapper objectMapper) { + ObjectMapper schemaMapper = ToolSchemaObjectMapper.create(objectMapper); return buildSchemaRecursive( - objectMapper.constructType(type), new SchemaGenerationContext(), objectMapper); + schemaMapper.constructType(type), new SchemaGenerationContext(), schemaMapper); } /** @@ -256,8 +271,13 @@ private static Schema buildSchemaRecursive( for (BeanPropertyDefinition property : beanDescription.findProperties()) { AnnotatedMember member = property.getPrimaryMember(); if (member != null) { - properties.put( - property.getName(), buildSchemaRecursive(member.getType(), context, objectMapper)); + Schema propertySchema = buildSchemaRecursive(member.getType(), context, objectMapper); + Annotations.Schema annotation = member.getAnnotation(Annotations.Schema.class); + if (annotation != null && !annotation.description().isEmpty()) { + propertySchema = + propertySchema.toBuilder().description(annotation.description()).build(); + } + properties.put(property.getName(), propertySchema); if (property.isRequired()) { required.add(property.getName()); } diff --git a/core/src/main/java/com/google/adk/tools/FunctionTool.java b/core/src/main/java/com/google/adk/tools/FunctionTool.java index 91abb10e2..a65d3d65d 100644 --- a/core/src/main/java/com/google/adk/tools/FunctionTool.java +++ b/core/src/main/java/com/google/adk/tools/FunctionTool.java @@ -204,11 +204,11 @@ protected FunctionTool( this.instance = instance; this.func = func; + this.objectMapper = ToolSchemaObjectMapper.create(objectMapper); this.funcDeclaration = FunctionCallingUtils.buildFunctionDeclaration( - this.func, ImmutableList.of("toolContext", "inputStream")); + this.func, ImmutableList.of("toolContext", "inputStream"), this.objectMapper); this.requireConfirmation = requireConfirmation; - this.objectMapper = objectMapper; } @Override diff --git a/core/src/main/java/com/google/adk/tools/ToolSchemaObjectMapper.java b/core/src/main/java/com/google/adk/tools/ToolSchemaObjectMapper.java new file mode 100644 index 000000000..908ca7639 --- /dev/null +++ b/core/src/main/java/com/google/adk/tools/ToolSchemaObjectMapper.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.tools; + +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyName; +import com.fasterxml.jackson.databind.introspect.Annotated; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector; + +/** Creates isolated mappers that use ADK annotations without mutating the application's mapper. */ +final class ToolSchemaObjectMapper { + + static ObjectMapper create(ObjectMapper source) { + ObjectMapper mapper = source.copy(); + AnnotationIntrospector schema = new SchemaIntrospector(); + mapper.setAnnotationIntrospectors( + AnnotationIntrospector.pair( + schema, source.getSerializationConfig().getAnnotationIntrospector()), + AnnotationIntrospector.pair( + schema, source.getDeserializationConfig().getAnnotationIntrospector())); + return mapper; + } + + private static final class SchemaIntrospector extends NopAnnotationIntrospector { + @Override + public PropertyName findNameForSerialization(Annotated annotated) { + return schemaName(annotated); + } + + @Override + public PropertyName findNameForDeserialization(Annotated annotated) { + return schemaName(annotated); + } + + @Override + public Boolean hasRequiredMarker(AnnotatedMember member) { + Annotations.Schema schema = member.getAnnotation(Annotations.Schema.class); + return schema == null ? null : !schema.optional(); + } + + private static PropertyName schemaName(Annotated annotated) { + Annotations.Schema schema = annotated.getAnnotation(Annotations.Schema.class); + if (schema == null) { + return null; + } + // An empty ADK name keeps Jackson's explicit name, or the default Java property name. + return schema.name().isEmpty() + ? PropertyName.USE_DEFAULT + : PropertyName.construct(schema.name()); + } + } + + private ToolSchemaObjectMapper() {} +} diff --git a/core/src/test/java/com/google/adk/tools/FunctionToolSchemaTest.java b/core/src/test/java/com/google/adk/tools/FunctionToolSchemaTest.java new file mode 100644 index 000000000..c0730b0b7 --- /dev/null +++ b/core/src/test/java/com/google/adk/tools/FunctionToolSchemaTest.java @@ -0,0 +1,357 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.adk.tools; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.google.adk.JsonBaseModel; +import com.google.genai.types.Schema; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Verifies that tool schemas and argument binding agree without changing business JSON. */ +@RunWith(JUnit4.class) +public final class FunctionToolSchemaTest { + + @Test + public void schemaAnnotation_overridesJacksonNameAndAddsDescription() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(Customer.class); + + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("customer_id"); + assertThat(schema.properties().orElseThrow().get("customer_id").description()) + .hasValue("The customer identifier"); + assertThat(schema.required().orElseThrow()).containsExactly("customer_id"); + } + + @Test + public void optionalAnnotation_overridesJacksonRequired() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(OptionalCustomer.class); + + assertThat(schema.required().orElse(List.of())).isEmpty(); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("business_id"); + } + + @Test + public void runAsync_bindsModelFacingName() { + FunctionTool tool = FunctionTool.create(Functions.class, "lookup"); + + Map result = + tool.runAsync(Map.of("request", Map.of("customer_id", "customer-42")), null).blockingGet(); + + assertThat(result).containsExactly("id", "customer-42"); + } + + @Test + public void runAsync_bindsNestedObjectsInLists() { + FunctionTool tool = FunctionTool.create(Functions.class, "lookupMany"); + + Map result = + tool.runAsync(Map.of("requests", List.of(Map.of("customer_id", "customer-42"))), null) + .blockingGet(); + + assertThat(result).containsExactly("id", "customer-42"); + } + + @Test + public void toolMapper_doesNotChangeSharedBusinessMapper() throws Exception { + var mapper = JsonBaseModel.getMapper(); + var introspector = mapper.getSerializationConfig().getAnnotationIntrospector(); + var unused = FunctionTool.create(Functions.class, "lookup"); + Customer customer = new Customer(); + customer.setId("customer-42"); + + assertThat(mapper.getSerializationConfig().getAnnotationIntrospector()) + .isSameInstanceAs(introspector); + assertThat(mapper.readTree(mapper.writeValueAsString(customer)).has("business_id")).isTrue(); + assertThat(mapper.readTree(mapper.writeValueAsString(customer)).has("customer_id")).isFalse(); + } + + @Test + public void fieldAnnotation_controlsNameDescriptionAndOptionality() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(FieldRequest.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("field_name"); + assertThat(schema.properties().orElseThrow().get("field_name").description()) + .hasValue("Field description"); + assertThat(schema.required().orElse(List.of())).isEmpty(); + + FunctionTool tool = FunctionTool.create(Functions.class, "readField"); + assertThat(tool.runAsync(Map.of("request", Map.of("field_name", "value")), null).blockingGet()) + .containsExactly("value", "value"); + } + + @Test + public void emptyAnnotationName_keepsJacksonName() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(OptionalCustomer.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("business_id"); + FunctionTool tool = FunctionTool.create(Functions.class, "readOptionalCustomer"); + assertThat(tool.runAsync(Map.of("request", Map.of("business_id", "value")), null).blockingGet()) + .containsExactly("id", "value"); + } + + @Test + public void annotatedPrivateField_isIncludedWithoutExposingOtherPrivateFields() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(PrivateFieldRequest.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("value"); + FunctionTool tool = FunctionTool.create(Functions.class, "readPrivateField"); + assertThat(tool.runAsync(Map.of("request", Map.of("value", "selected")), null).blockingGet()) + .containsExactly("value", "selected"); + } + + @Test + public void recordComponents_supportNamesAndOptionalCreatorProperties() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(RecordRequest.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("customer_id", "note"); + assertThat(schema.required().orElseThrow()).containsExactly("customer_id"); + assertThat(schema.properties().orElseThrow().get("customer_id").description()) + .hasValue("Record identifier"); + FunctionTool tool = FunctionTool.create(Functions.class, "readRecord"); + assertThat( + tool.runAsync(Map.of("request", Map.of("customer_id", "record-42")), null) + .blockingGet()) + .containsExactly("id", "record-42", "hasNote", false); + } + + @Test + public void setterAnnotation_agreesWithSchemaAndBinding() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(SetterRequest.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("setter_id"); + assertThat(schema.properties().orElseThrow().get("setter_id").description()) + .hasValue("Setter identifier"); + FunctionTool tool = FunctionTool.create(Functions.class, "readSetter"); + assertThat( + tool.runAsync(Map.of("request", Map.of("setter_id", "setter-42")), null).blockingGet()) + .containsExactly("id", "setter-42"); + } + + @Test + public void nestedObjectProperty_preservesGenericElementBinding() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(Group.class); + Schema element = schema.properties().orElseThrow().get("members").items().orElseThrow(); + assertThat(element.properties().orElseThrow().keySet()).containsExactly("customer_id"); + FunctionTool tool = FunctionTool.create(Functions.class, "readGroup"); + assertThat( + tool.runAsync( + Map.of( + "request", Map.of("members", List.of(Map.of("customer_id", "nested-42")))), + null) + .blockingGet()) + .containsExactly("id", "nested-42"); + } + + @Test + public void jsonIgnore_stillWinsOverSchemaAnnotation() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(IgnoredRequest.class); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("visible"); + } + + @Test + public void unannotatedPojo_keepsJacksonNameAndRequiredFlag() { + Schema schema = FunctionCallingUtils.buildSchemaFromType(LegacyRequest.class); + assertThat(schema.properties().orElseThrow().keySet()) + .containsExactly("business_name", "limit"); + assertThat(schema.required().orElseThrow()).containsExactly("business_name"); + } + + @Test + public void customMapper_configurationIsUsedForSchemaAndBindingWithoutMutation() + throws Exception { + ObjectMapper mapper = + JsonBaseModel.getMapper() + .copy() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + var original = mapper.getDeserializationConfig().getAnnotationIntrospector(); + FunctionTool tool = new CustomMapperTool(mapper); + Schema request = + tool.declaration() + .orElseThrow() + .parameters() + .orElseThrow() + .properties() + .orElseThrow() + .get("request"); + assertThat(request.properties().orElseThrow().keySet()).containsExactly("display_name"); + assertThat(tool.runAsync(Map.of("request", Map.of("display_name", "Ada")), null).blockingGet()) + .containsExactly("name", "Ada"); + assertThat(mapper.getDeserializationConfig().getAnnotationIntrospector()) + .isSameInstanceAs(original); + } + + @Test + public void annotatedReturnValue_matchesResponseSchema() { + FunctionTool tool = FunctionTool.create(Functions.class, "currentCustomer"); + assertThat( + tool.declaration() + .orElseThrow() + .response() + .orElseThrow() + .properties() + .orElseThrow() + .keySet()) + .containsExactly("customer_id"); + assertThat(tool.runAsync(Map.of(), null).blockingGet()) + .containsExactly("customer_id", "result-42"); + } + + public static final class Customer { + private String id; + + @JsonProperty("business_id") + @Annotations.Schema(name = "customer_id", description = "The customer identifier") + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + + public static final class OptionalCustomer { + private String id; + + @JsonProperty(value = "business_id", required = true) + @Annotations.Schema(optional = true) + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + + public static final class Functions { + public static Map lookup( + @Annotations.Schema(name = "request") Customer request) { + return Map.of("id", request.getId()); + } + + public static Map lookupMany( + @Annotations.Schema(name = "requests") List requests) { + return Map.of("id", requests.get(0).getId()); + } + + public static Map readField( + @Annotations.Schema(name = "request") FieldRequest request) { + return Map.of("value", request.value); + } + + public static Map readOptionalCustomer( + @Annotations.Schema(name = "request") OptionalCustomer request) { + return Map.of("id", request.getId()); + } + + public static Map readPrivateField( + @Annotations.Schema(name = "request") PrivateFieldRequest request) { + return Map.of("value", request.value); + } + + public static Map readRecord( + @Annotations.Schema(name = "request") RecordRequest request) { + return Map.of("id", request.customerId(), "hasNote", request.note() != null); + } + + public static Map readSetter( + @Annotations.Schema(name = "request") SetterRequest request) { + return Map.of("id", request.getId()); + } + + public static Map readGroup( + @Annotations.Schema(name = "request") Group request) { + return Map.of("id", request.customers.get(0).getId()); + } + + public static Map readPlain( + @Annotations.Schema(name = "request") PlainRequest request) { + return Map.of("name", request.displayName); + } + + public static Customer currentCustomer() { + Customer customer = new Customer(); + customer.setId("result-42"); + return customer; + } + } + + public static final class FieldRequest { + @JsonProperty(value = "business_field", required = true) + @Annotations.Schema(name = "field_name", description = "Field description", optional = true) + public String value; + } + + public static final class PrivateFieldRequest { + @Annotations.Schema(description = "Visible tool field") + private String value; + + private String hidden; + } + + public record RecordRequest( + @JsonProperty("business_id") + @Annotations.Schema(name = "customer_id", description = "Record identifier") + String customerId, + @JsonProperty(required = true) @Annotations.Schema(optional = true) String note) {} + + public static final class SetterRequest { + private String id; + + public String getId() { + return id; + } + + @Annotations.Schema(name = "setter_id", description = "Setter identifier") + public void setId(String id) { + this.id = id; + } + } + + public static final class Group { + @Annotations.Schema(name = "members") + public List customers; + } + + public static final class IgnoredRequest { + @JsonIgnore + @Annotations.Schema(name = "secret") + public String ignored; + + public String visible; + } + + public static final class LegacyRequest { + @JsonProperty(value = "business_name", required = true) + public String name; + + public int limit; + } + + public static final class PlainRequest { + public String displayName; + } + + private static final class CustomMapperTool extends FunctionTool { + CustomMapperTool(ObjectMapper mapper) throws NoSuchMethodException { + super(null, Functions.class.getMethod("readPlain", PlainRequest.class), false, mapper); + } + } +}