From 020c95ccf35506abca8700a32a7e61fb40a20ca7 Mon Sep 17 00:00:00 2001 From: Mateusz Krawiec Date: Fri, 6 Nov 2026 03:07:01 -0800 Subject: [PATCH] feat: infer schemas for FunctionNode ports PiperOrigin-RevId: 1015405856 --- .../com/google/adk/workflow/FunctionNode.java | 34 +- .../com/google/adk/workflow/PortCodec.java | 23 +- .../com/google/adk/workflow/PortSchemas.java | 360 ++++++++++ .../adk/workflow/RequiredProperties.java | 11 +- .../google/adk/workflow/FunctionNodeTest.java | 623 ++++++++++++++++++ 5 files changed, 1041 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/com/google/adk/workflow/PortSchemas.java diff --git a/core/src/main/java/com/google/adk/workflow/FunctionNode.java b/core/src/main/java/com/google/adk/workflow/FunctionNode.java index 57a2e6200..ef14143cd 100644 --- a/core/src/main/java/com/google/adk/workflow/FunctionNode.java +++ b/core/src/main/java/com/google/adk/workflow/FunctionNode.java @@ -72,12 +72,38 @@ public Builder toBuilder() { .rerunOnResume(rerunOnResume()) .waitForOutput(waitForOutput()) .config(config()) - .inputSchema(inputSchema().orElse(null)) - .outputSchema(outputSchema().orElse(null)) + // Only explicit schemas carry over; the copy infers the others from its ports. + .inputSchema(super.inputSchema().orElse(null)) + .outputSchema(super.outputSchema().orElse(null)) .stateSchema(stateSchema().orElse(null)) .body(body); } + /** + * Returns the schema that describes the input port: the one set on the builder or, if none, one + * inferred from the input type. An {@code Object}, {@code Void} or other type with no fixed JSON + * shape infers none. + */ + @Override + public Optional inputSchema() { + return super.inputSchema().or(() -> Optional.ofNullable(inputCodec.schema())); + } + + /** + * Returns the schema that describes the output port: the one set on the builder or, if none, one + * inferred from the output type. A returned {@code Event} is sent as it is, so an {@code Event} + * output infers none. + */ + @Override + public Optional outputSchema() { + return super.outputSchema().or(() -> Optional.ofNullable(outputCodec.schema())); + } + + /** Whether {@link #inputSchema} was inferred rather than set on the builder. */ + boolean inputSchemaInferred() { + return super.inputSchema().isEmpty() && inputCodec.schema() != null; + } + @Override public Flowable runNode(Context context, @Nullable Object nodeInput) { return Flowable.defer( @@ -201,14 +227,14 @@ public Builder config(NodeConfig config) { return this; } - /** Sets the schema the node's input must match. */ + /** Sets the schema that describes the node's input, in place of one inferred from its type. */ @CanIgnoreReturnValue public Builder inputSchema(@Nullable Schema inputSchema) { this.inputSchema = inputSchema; return this; } - /** Sets the schema the node's output must match. */ + /** Sets the schema that describes the node's output, in place of one inferred from its type. */ @CanIgnoreReturnValue public Builder outputSchema(@Nullable Schema outputSchema) { this.outputSchema = outputSchema; diff --git a/core/src/main/java/com/google/adk/workflow/PortCodec.java b/core/src/main/java/com/google/adk/workflow/PortCodec.java index e8a76b870..57e08a367 100644 --- a/core/src/main/java/com/google/adk/workflow/PortCodec.java +++ b/core/src/main/java/com/google/adk/workflow/PortCodec.java @@ -61,8 +61,11 @@ import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.adk.events.Event; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.genai.JsonSerializable; import com.google.genai.types.Content; +import com.google.genai.types.Schema; import java.io.IOException; import java.lang.invoke.MethodType; import java.lang.reflect.GenericArrayType; @@ -123,16 +126,23 @@ final class PortCodec { /** The port's type, with a primitive boxed. */ private final JavaType type; + private final Supplier<@Nullable Schema> schema; + /** Whether the declared type is primitive, so the port takes no null. */ private final boolean primitive; - private PortCodec(JavaType declared) { + private PortCodec(JavaType declared, boolean output) { this.primitive = declared.isPrimitive(); this.type = primitive ? MAPPER.constructType( MethodType.methodType(declared.getRawClass()).wrap().returnType()) : declared; + // An event output passes through as it is, so no JSON form describes it. + this.schema = + output && type.getRawClass() == Event.class + ? () -> null + : Suppliers.memoize(() -> PortSchemas.infer(type, output)); } static JavaType javaType(Class type) { @@ -188,7 +198,7 @@ static PortCodec input(JavaType type, String what) { if (!passesAsIs(type)) { requireUsable(type, what, /* read= */ true); } - return new PortCodec<>(type); + return new PortCodec<>(type, /* output= */ false); } /** @@ -201,7 +211,7 @@ static PortCodec output(JavaType type, String what) { if (!passesAsIs(type)) { requireUsable(type, what, /* read= */ false); } - return new PortCodec<>(type); + return new PortCodec<>(type, /* output= */ true); } /** Whether a port of {@code type} handles values without a Jackson check. */ @@ -430,7 +440,7 @@ private static boolean handlesItself(JavaType type, boolean read) { } } - private static @Nullable TypeSerializer findTypeSerializer(JavaType type) { + static @Nullable TypeSerializer findTypeSerializer(JavaType type) { try { return MAPPER.getSerializerProviderInstance().findTypeSerializer(type); } catch (JsonMappingException | RuntimeException e) { @@ -484,6 +494,11 @@ private static String capitalized(String what) { return Character.toUpperCase(what.charAt(0)) + what.substring(1); } + /** Returns the schema inferred for this port's JSON form, or {@code null} if it has none. */ + @Nullable Schema schema() { + return schema.get(); + } + /** * Returns {@code value} read as this port's type; {@code what} names the port in errors. * diff --git a/core/src/main/java/com/google/adk/workflow/PortSchemas.java b/core/src/main/java/com/google/adk/workflow/PortSchemas.java new file mode 100644 index 000000000..81a7d14c0 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/PortSchemas.java @@ -0,0 +1,360 @@ +/* + * 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.workflow; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Comparator.comparingInt; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyName; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.deser.BeanDeserializerBase; +import com.fasterxml.jackson.databind.deser.CreatorProperty; +import com.fasterxml.jackson.databind.deser.DefaultDeserializationContext; +import com.fasterxml.jackson.databind.deser.SettableBeanProperty; +import com.fasterxml.jackson.databind.deser.ValueInstantiator; +import com.fasterxml.jackson.databind.introspect.AnnotatedMember; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.std.BeanSerializerBase; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Streams; +import com.google.genai.JsonSerializable; +import com.google.genai.types.Schema; +import com.google.genai.types.Type; +import java.lang.invoke.MethodType; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * Infers the schema of a {@link FunctionNode} port's JSON form from the port's type. An input + * port's schema lists the keys its Jackson mapper reads, and an output port's the keys it writes. + */ +final class PortSchemas { + + // Jackson writes a char[] as a string, like a byte[] in base64. + private static final ImmutableSet> STRINGS = + ImmutableSet.of( + String.class, CharSequence.class, Character.class, byte[].class, char[].class); + private static final ImmutableSet> INTEGERS = + ImmutableSet.of(Integer.class, Long.class, Short.class, Byte.class, BigInteger.class); + private static final ImmutableSet> NUMBERS = + ImmutableSet.of(Float.class, Double.class, BigDecimal.class, Number.class); + private static final SerializationConfig CONFIG = PortCodec.MAPPER.getSerializationConfig(); + private static final AnnotationIntrospector ANNOTATIONS = CONFIG.getAnnotationIntrospector(); + + /** Whether this describes what an output port writes rather than what an input port reads. */ + private final boolean output; + + /** The records and POJOs being described, to find a type that contains itself. */ + private final Set enclosing = new HashSet<>(); + + private boolean recursive; + + private PortSchemas(boolean output) { + this.output = output; + } + + /** + * Returns the schema of {@code type}'s JSON form as an output port writes it or an input port + * reads it, or {@code null} if that form has no fixed shape or {@code type} contains itself. + */ + static @Nullable Schema infer(JavaType type, boolean output) { + PortSchemas inference = new PortSchemas(output); + Schema schema = inference.describe(type); + return inference.recursive ? null : schema; + } + + private @Nullable Schema describe(JavaType type) { + // Boxes a primitive, so each set lists only the boxed type. + Class raw = MethodType.methodType(type.getRawClass()).wrap().returnType(); + if (STRINGS.contains(raw)) { + return typed(Type.Known.STRING); + } + if (raw == Boolean.class) { + return typed(Type.Known.BOOLEAN); + } + if (INTEGERS.contains(raw)) { + return typed(Type.Known.INTEGER); + } + if (NUMBERS.contains(raw)) { + return typed(Type.Known.NUMBER); + } + if (raw.isEnum()) { + return describeEnum(raw); + } + if (type.isReferenceType()) { + Schema content = describe(type.getContentType()); + return content == null ? null : content.toBuilder().nullable(true).build(); + } + if (type.isArrayType() || type.isCollectionLikeType()) { + Schema items = describe(type.getContentType()); + Schema.Builder array = Schema.builder().type(Type.Known.ARRAY); + return (items == null ? array : array.items(items)).build(); + } + if (type.isMapLikeType()) { + return typed(Type.Known.OBJECT); + } + return describeObject(type); + } + + /** Returns a string enum of the values the mapper writes, or {@code null} if one is not text. */ + private static @Nullable Schema describeEnum(Class type) { + ImmutableList values; + try { + values = + Arrays.stream(type.getEnumConstants()) + .map(constant -> PortCodec.MAPPER.valueToTree(constant)) + .collect(toImmutableList()); + } catch (RuntimeException e) { + return null; + } + return values.stream().allMatch(JsonNode::isTextual) + ? Schema.builder() + .type(Type.Known.STRING) + .enum_(values.stream().map(JsonNode::textValue).collect(toImmutableList())) + .build() + : null; + } + + /** Returns the schema of a record or POJO, or {@code null} if its keys are not fixed. */ + private @Nullable Schema describeObject(JavaType type) { + // A polymorphic type also has its subtypes' properties. + Bean bean = PortCodec.findTypeSerializer(type) == null ? bean(type) : null; + if (bean == null || !hasFixedKeys(type)) { + return null; + } + if (!enclosing.add(type)) { + recursive = true; + return null; + } + BeanDescription description = CONFIG.introspect(type); + ImmutableMap.Builder properties = ImmutableMap.builder(); + ImmutableList.Builder required = ImmutableList.builder(); + for (BeanProperty property : bean.properties()) { + JavaType propertyType = property.getType(); + boolean nullable = + !propertyType.isPrimitive() + && (propertyType.isReferenceType() + || RequiredProperties.hasNullableAnnotation(description, property)); + Class raw = propertyType.getRawClass(); + boolean emptiable = + nullable + || propertyType.isContainerType() + || raw == Object.class + || CharSequence.class.isAssignableFrom(raw) + || Iterable.class.isAssignableFrom(raw) + || JsonNode.class.isAssignableFrom(raw); + // The mapper omits a key only when its value can be one the inclusion leaves out. + JsonInclude.Include include = inclusion(type, property); + boolean omitted = + switch (include) { + case NON_NULL, NON_ABSENT -> nullable; + case NON_EMPTY -> emptiable; + case NON_DEFAULT -> + emptiable || MethodType.methodType(raw).unwrap().returnType().isPrimitive(); + case CUSTOM -> true; + default -> false; + }; + if (output && omitted && include != JsonInclude.Include.CUSTOM) { + // Of the inclusions that omit null, only NON_NULL writes an empty Optional as null. + nullable = include == JsonInclude.Include.NON_NULL && propertyType.isReferenceType(); + } + if (hasElementTypeId(property)) { + propertyType = propertyType.withContentType(TypeFactory.unknownType()); + } + // Whether an Optional is written as null depends on its inclusion, as computed above. + if (propertyType.isReferenceType()) { + propertyType = propertyType.getContentType(); + } + // A serializer, type id, object id or shape of the property's own may use another type. + Schema schema = hasOwnForm(property) ? null : describe(propertyType); + if (schema == null) { + // A property of no fixed shape takes any value. + schema = Schema.builder().build(); + } else if (nullable) { + schema = schema.toBuilder().nullable(true).build(); + } + properties.put(property.getName(), schema); + // Any of an aliased property's names may carry it, so none of them is required. + List aliases = output ? ImmutableList.of() : property.findAliases(CONFIG); + for (PropertyName alias : aliases) { + properties.put(alias.getSimpleName(), schema); + } + // An input needs the keys the runtime fails a value without; Jackson enforces required = true + // only on a creator parameter. + if (output + ? !omitted + : aliases.isEmpty() + && ((property instanceof CreatorProperty && property.isRequired()) + || bean.required().contains(property.getName()))) { + required.add(property.getName()); + } + } + enclosing.remove(type); + ImmutableList requiredNames = required.build(); + Schema.Builder object = + Schema.builder().type(Type.Known.OBJECT).properties(properties.buildKeepingLast()); + return (requiredNames.isEmpty() ? object : object.required(requiredNames)).build(); + } + + /** The properties a port writes or reads, and those an input port fails a value without. */ + private record Bean( + ImmutableList properties, ImmutableSet required) {} + + /** Returns what the port writes or reads of {@code type}, or {@code null} if it is no bean. */ + private @Nullable Bean bean(JavaType type) { + ObjectMapper mapper = mapper(type); + Object handler; + try { + handler = + output + ? mapper.getSerializerProviderInstance().findValueSerializer(type) + : ((DefaultDeserializationContext) mapper.getDeserializationContext()) + .createDummyInstance(mapper.getDeserializationConfig()) + .findRootValueDeserializer(type); + } catch (JsonMappingException | RuntimeException e) { + return null; + } + if (handler instanceof BeanSerializerBase serializer) { + return new Bean(ImmutableList.copyOf(serializer.properties()), ImmutableSet.of()); + } + BeanDeserializerBase deserializer = handler instanceof BeanDeserializerBase bean ? bean : null; + ValueInstantiator creator = deserializer == null ? null : deserializer.getValueInstantiator(); + // A delegating creator reads the form of another type. + if (creator == null + || !(creator.canCreateUsingDefault() || creator.canCreateFromObjectWith())) { + return null; + } + ImmutableList declared = + CONFIG.introspect(type).findProperties().stream() + .map(BeanPropertyDefinition::getName) + .collect(toImmutableList()); + // Jackson keeps an ignored creator parameter, to pass it null. + return new Bean( + Streams.stream(deserializer.properties()) + .filter(property -> !property.isIgnorable()) + .sorted(comparingInt(property -> declared.indexOf(property.getName()))) + .collect(toImmutableList()), + RequiredProperties.required(creator)); + } + + /** Returns the mapper that handles {@code type}: genai's own for a genai type, as a port does. */ + private static ObjectMapper mapper(JavaType type) { + return JsonSerializable.class.isAssignableFrom(type.getRawClass()) + ? JsonSerializable.objectMapper() + : PortCodec.MAPPER; + } + + /** Returns how the mapper includes a property on write: its own, else its class's or mapper's. */ + private static JsonInclude.Include inclusion(JavaType type, BeanProperty property) { + SerializationConfig config = mapper(type).getSerializationConfig(); + BeanDescription description = config.introspect(type); + JsonInclude.Value include = + description.findPropertyInclusion(config.getDefaultPropertyInclusion(type.getRawClass())); + JsonInclude.Value own = + property.getMember() == null + ? JsonInclude.Value.empty() + : ANNOTATIONS.findPropertyInclusion(property.getMember()); + JsonInclude.Include value = include.withOverrides(own).getValueInclusion(); + // A class-level NON_DEFAULT compares each value with a default instance, if Jackson can create + // one, so it may omit any value, as an unknown filter may. + return value == JsonInclude.Include.NON_DEFAULT + && include.getValueInclusion() == JsonInclude.Include.NON_DEFAULT + && description.findDefaultConstructor() != null + ? JsonInclude.Include.CUSTOM + : value; + } + + /** + * Whether the keys of {@code type} are the names of its properties, and it has no any-setter to + * take other keys. An any-getter, an unwrapped property, an object id or a shape other than an + * object uses other keys, or no object. + */ + private static boolean hasFixedKeys(JavaType type) { + try { + BeanDescription description = CONFIG.introspect(type); + JsonFormat.Value format = description.findExpectedFormat(); + return description.findAnyGetter() == null + && description.findAnySetterAccessor() == null + && description.getObjectIdInfo() == null + && (!format.hasShape() || format.getShape() == JsonFormat.Shape.OBJECT) + && description.findProperties().stream() + .flatMap(RequiredProperties::members) + .noneMatch(member -> ANNOTATIONS.findUnwrappingNameTransformer(member) != null); + } catch (RuntimeException e) { + // Jackson rejects a malformed or second any-getter or any-setter. + return false; + } + } + + /** + * Whether {@code property} has a serializer or deserializer, converter, type id, object id or + * shape of its own. A type id on a container goes on each element; see {@link #hasElementTypeId}. + */ + private boolean hasOwnForm(BeanProperty property) { + if ((property instanceof BeanPropertyWriter writer && writer.getTypeSerializer() != null) + || (property instanceof SettableBeanProperty settable + && settable.getValueTypeDeserializer() != null)) { + return true; + } + AnnotatedMember member = property.getMember(); + if (member == null) { + return false; + } + JsonFormat.Value format = ANNOTATIONS.findFormat(member); + boolean ownCodec = + output + ? ANNOTATIONS.findSerializer(member) != null + || ANNOTATIONS.findContentSerializer(member) != null + || ANNOTATIONS.findSerializationConverter(member) != null + : ANNOTATIONS.findDeserializer(member) != null + || ANNOTATIONS.findContentDeserializer(member) != null + || ANNOTATIONS.findDeserializationConverter(member) != null; + return ownCodec + || ANNOTATIONS.findObjectIdInfo(member) != null + || (format != null && format.hasShape()); + } + + /** Whether the {@code @JsonTypeInfo} of a container property puts a type id on each element. */ + private static boolean hasElementTypeId(BeanProperty property) { + JavaType type = property.getType(); + return property.getMember() != null + && (type.isContainerType() || type.isReferenceType()) + && ANNOTATIONS.findPropertyContentTypeResolver(CONFIG, property.getMember(), type) != null; + } + + private static Schema typed(Type.Known type) { + return Schema.builder().type(type).build(); + } +} diff --git a/core/src/main/java/com/google/adk/workflow/RequiredProperties.java b/core/src/main/java/com/google/adk/workflow/RequiredProperties.java index 3c3604263..5c09c8b70 100644 --- a/core/src/main/java/com/google/adk/workflow/RequiredProperties.java +++ b/core/src/main/java/com/google/adk/workflow/RequiredProperties.java @@ -77,6 +77,13 @@ static ValueInstantiator enforce( return new Checking(creator, required); } + /** + * Returns the creator properties {@code instantiator} fails a value without, if it checks any. + */ + static ImmutableSet required(ValueInstantiator instantiator) { + return instantiator instanceof Checking checking ? checking.required : ImmutableSet.of(); + } + private static boolean isRequired( SerializationConfig config, JavaType type, @@ -114,7 +121,7 @@ private static boolean isRequired( * Whether {@code property} is annotated {@code @Nullable}, from whichever package, on the member * the mapper uses or on its field, getter, setter or creator parameter. */ - private static boolean hasNullableAnnotation(BeanDescription description, BeanProperty property) { + static boolean hasNullableAnnotation(BeanDescription description, BeanProperty property) { Stream members = description.findProperties().stream() .filter(definition -> definition.getName().equals(property.getName())) @@ -132,7 +139,7 @@ private static boolean hasNullableAnnotation(BeanDescription description, BeanPr } /** Returns the field, getter, setter and creator parameter of {@code definition} it has. */ - private static Stream members(BeanPropertyDefinition definition) { + static Stream members(BeanPropertyDefinition definition) { Stream members = Stream.of(definition.getField(), definition.getConstructorParameter()); // Jackson rejects overloaded getters or setters it cannot choose between. diff --git a/core/src/test/java/com/google/adk/workflow/FunctionNodeTest.java b/core/src/test/java/com/google/adk/workflow/FunctionNodeTest.java index dd0ad80db..3d82ceb6f 100644 --- a/core/src/test/java/com/google/adk/workflow/FunctionNodeTest.java +++ b/core/src/test/java/com/google/adk/workflow/FunctionNodeTest.java @@ -22,24 +22,36 @@ import static com.google.adk.workflow.WorkflowResumeTesting.go; import static com.google.adk.workflow.WorkflowResumeTesting.longRunningAsk; import static com.google.adk.workflow.WorkflowResumeTesting.replyInput; +import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.MoreCollectors.onlyElement; import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.Assert.assertThrows; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonIdentityReference; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; import com.fasterxml.jackson.annotation.OptBoolean; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.databind.util.StdConverter; import com.google.adk.apps.App; import com.google.adk.events.Event; import com.google.adk.events.EventActions; @@ -52,6 +64,7 @@ import com.google.genai.types.FinishReason; import com.google.genai.types.Part; import com.google.genai.types.Schema; +import com.google.genai.types.Type; import com.google.genai.types.VideoMetadata; import io.reactivex.rxjava3.core.Scheduler; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -62,6 +75,7 @@ import java.math.BigInteger; import java.net.URI; import java.time.Duration; +import java.time.Instant; import java.time.ZoneId; import java.util.ArrayList; import java.util.HashMap; @@ -69,6 +83,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -85,6 +100,11 @@ public final class FunctionNodeTest { private static final String APP = "app"; private static final String USER = "user"; + private static final Schema ANY = Schema.builder().build(); + private static final Schema STRING = typed(Type.Known.STRING); + private static final Schema INTEGER = typed(Type.Known.INTEGER); + private static final Schema ORDER = + object(ImmutableMap.of("id", STRING, "quantity", INTEGER), "id", "quantity"); record Order(String id, int quantity) {} @@ -264,6 +284,223 @@ public String getValue() { } } + record Note( + String title, + int pages, + Optional subtitle, + @Nullable String body, + @OtherNullness.Nullable String footer) {} + + /** A record whose JSON form renames one property, ignores one and does not require two. */ + record Draft( + @JsonProperty("section_name") String sectionName, + @JsonProperty(isRequired = OptBoolean.FALSE) String content, + @JsonProperty(isRequired = OptBoolean.FALSE) int priority, + @JsonIgnore String notes) {} + + enum Status { + OPEN, + SHIPPED + } + + record Shipment(Status status, Order order, List history, Map byId) {} + + enum Color { + @JsonProperty("red") + RED, + @JsonProperty("dark-blue") + BLUE + } + + /** An enum that Jackson writes as a number. */ + enum Level { + LOW(1), + HIGH(2); + + private final int code; + + Level(int code) { + this.code = code; + } + + @JsonValue + int code() { + return code; + } + } + + /** A record that also writes the entries of a map as keys of its own. */ + record Tagged(String name) { + @JsonAnyGetter + ImmutableMap extra() { + return ImmutableMap.of("k", 1); + } + } + + /** A class whose any-setter takes keys it does not declare. */ + static final class Catchall { + public String id; + + @JsonAnySetter + void put(String key, Object value) {} + } + + /** A class with two any-setters, which Jackson rejects. */ + static final class TwoCatchalls { + public String id; + + @JsonAnySetter + void put(String key, Object value) {} + + @JsonAnySetter + void add(String key, Object value) {} + } + + record Flat(String label, @JsonUnwrapped Order order) {} + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + record Pair(int x, int y) {} + + /** A polymorphic base type with a property of its own. */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) + abstract static class Pet { + public String name; + } + + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class) + record Ref(String label) {} + + /** A record whose properties have no fixed JSON type. */ + record Irregular( + @JsonSerialize(using = ToStringSerializer.class) long id, + @JsonSerialize(contentUsing = ToStringSerializer.class) List codes, + @JsonFormat(shape = JsonFormat.Shape.STRING) int count, + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) Order typed, + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) List typedOrders, + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class) + @JsonIdentityReference(alwaysAsId = true) + Order byId, + Tagged tagged, + Catchall catchall, + TwoCatchalls twoCatchalls, + Flat flat, + Pair pair, + Ref ref, + Pet pet, + Level level, + Instant at, + Object raw, + List pets) {} + + record Measure(Number amount, CharSequence unit) {} + + /** A record whose JSON form may omit all but two keys. */ + record Sparse( + @JsonInclude(JsonInclude.Include.NON_EMPTY) String text, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) int count, + @JsonInclude(JsonInclude.Include.NON_ABSENT) Optional model, + @JsonInclude(JsonInclude.Include.NON_NULL) Optional label, + @JsonSerialize(converter = NumberToText.class) int code, + String plain) {} + + /** A record whose class-level inclusion omits only the keys that can be null. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record Counted(int count, String label, @Nullable String note, Optional tag) {} + + record Aliased(@JsonAlias("qty") int quantity) {} + + /** A record whose inclusions may omit keys, though none of its values can be null or empty. */ + record Lenient( + @JsonInclude(JsonInclude.Include.NON_ABSENT) String id, + @JsonInclude(JsonInclude.Include.NON_EMPTY) int count, + @JsonInclude(JsonInclude.Include.NON_EMPTY) Order order, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) Status status) {} + + /** A record that reads a password it never writes, and writes a role it never reads. */ + record Login( + String user, + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password, + @JsonProperty(access = JsonProperty.Access.READ_ONLY) String role) {} + + /** A class whose creator parameter has no getter or field, so the mapper never writes it. */ + static final class Greeting { + public final String text; + + @JsonCreator + Greeting(@JsonProperty("name") String name) { + this.text = "hi " + name; + } + } + + record Strict(@JsonProperty(required = true) @Nullable String id) {} + + /** A field marked required, which Jackson does not enforce outside a creator. */ + static final class MarkedField { + @JsonProperty(required = true) + public @Nullable String id; + } + + /** A setter marked required, which Jackson does not enforce outside a creator. */ + static final class MarkedSetter { + private @Nullable String id; + + public @Nullable String getId() { + return id; + } + + @JsonProperty(required = true) + public void setId(@Nullable String id) { + this.id = id; + } + } + + record AliasedMarked( + @JsonProperty(value = "quantity", required = true) @JsonAlias("qty") int q) {} + + static final class NumberToText extends StdConverter { + @Override + public String convert(Integer value) { + return value.toString(); + } + } + + /** A class whose field alone is annotated nullable. */ + static final class Memo { + private @Nullable String note; + + public String getNote() { + return note; + } + } + + /** A class whose creator parameter alone is annotated nullable. */ + static final class Signed { + public final String name; + + @JsonCreator + Signed(@JsonProperty("name") @Nullable String name) { + this.name = name; + } + } + + /** A record whose explicit accessor drops its component's annotation. */ + record Remark(@Nullable String note) { + @Override + public String note() { + return note; + } + } + + record TreeNode(String label, List children) {} + + /** Holds a declaration annotation named like the {@code @Nullable} of other packages. */ + static final class OtherNullness { + @Retention(RetentionPolicy.RUNTIME) + @interface Nullable {} + + private OtherNullness() {} + } + @Test public void of_recordPortFedAMap_readsTheRecord() { // A map output feeds a record port. @@ -771,6 +1008,362 @@ public void toBuilder_copy_keepsThePortsAndTheBody() { assertThat(result.outputAt("wf@1/copy@1")).isEqualTo("a1"); } + @Test + public void toBuilder_copy_keepsOnlyTheExplicitSchema() { + // Arrange + Schema explicit = Schema.builder().type(Type.Known.OBJECT).build(); + FunctionNode node = + FunctionNodes.builder(Order.class, Order.class) + .name("n") + .inputSchema(explicit) + .body((ctx, order) -> order) + .build(); + + // Act + FunctionNode copy = node.toBuilder().name("copy").build(); + + // Assert: the explicit schema wins over the inferred one in the node and in its copy. + assertThat(node.inputSchema()).hasValue(explicit); + assertThat(copy.inputSchema()).hasValue(explicit); + assertThat(copy.inputSchemaInferred()).isFalse(); + assertThat(copy.outputSchema()).hasValue(ORDER); + } + + @Test + public void of_inputWithoutAKeyTheInferredSchemaRequires_failsWithAValidationError() { + // Arrange: the inferred Order schema requires a quantity, which the input lacks. + Node makeOrder = emitter("make_order", ImmutableMap.of("id", "a1", "note", "rush")); + FunctionNode describe = + FunctionNodes.of( + "describe", + Order.class, + String.class, + (ctx, order) -> order.id() + " x" + order.quantity()); + + // Act + RunResult result = run(chain(makeOrder, describe)); + + // Assert: the port enforces the key the inferred schema requires. + assertThat(describe.inputSchemaInferred()).isTrue(); + assertThat(describe.inputSchema().orElseThrow().required()) + .hasValue(ImmutableList.of("id", "quantity")); + assertThat(result.errorMessage("NodeInputValidationException")) + .isEqualTo( + "validation error: input of node 'describe' does not match " + + Order.class.getName() + + "."); + } + + @Test + public void of_recordWithOrWithoutAnOptionalKey_readsTheValueOrEmpty() { + // Arrange: the inferred Note schema does not require subtitle, so it may arrive without one. + FunctionNode read = + FunctionNodes.of( + "read", Note.class, String.class, (ctx, note) -> String.valueOf(note.subtitle())); + Node without = emitter("make", ImmutableMap.of("title", "t", "pages", 1)); + Node with = emitter("make", ImmutableMap.of("title", "t", "pages", 1, "subtitle", "s")); + + // Act + RunResult missing = run(chain(without, read)); + RunResult present = run(chain(with, read)); + + // Assert + missing.assertCompleted(); + present.assertCompleted(); + assertThat(missing.outputAt("wf@1/read@1")).isEqualTo("Optional.empty"); + assertThat(present.outputAt("wf@1/read@1")).isEqualTo("Optional[s]"); + } + + @Test + public void inputSchema_scalarAndArrayTypes_mapToTheirJsonTypes() { + // Arrange: as in ADK Kotlin, a long is an INTEGER, unlike in a tool declaration. + Schema number = typed(Type.Known.NUMBER); + ImmutableMap, Schema> expected = + ImmutableMap., Schema>builder() + .put(String.class, STRING) + .put(byte[].class, STRING) + .put(char[].class, STRING) + .put(String[].class, Schema.builder().type(Type.Known.ARRAY).items(STRING).build()) + .put(boolean.class, typed(Type.Known.BOOLEAN)) + .put(int.class, INTEGER) + .put(long.class, INTEGER) + .put(Double.class, number) + .put( + Measure.class, + object(ImmutableMap.of("amount", number, "unit", STRING), "amount", "unit")) + .buildOrThrow(); + + // Act + ImmutableMap, Schema> actual = + expected.keySet().stream() + .collect(toImmutableMap(type -> type, type -> inputSchema(type).orElseThrow())); + + // Assert + assertThat(actual).isEqualTo(expected); + } + + @Test + public void inputSchema_optionalOrNullableProperties_areNullableAndNotRequired() { + // Act + Optional schema = inputSchema(Note.class); + + // Assert: any annotation named Nullable counts, a type-use one included. + Schema text = nullable(STRING); + ImmutableMap properties = + ImmutableMap.of( + "title", STRING, "pages", INTEGER, "subtitle", text, "body", text, "footer", text); + assertThat(schema).hasValue(object(properties, "title", "pages")); + } + + @Test + public void inputSchema_jacksonAnnotations_renameIgnoreAliasOrUnrequireProperties() { + // Act + Optional schema = inputSchema(Draft.class); + Optional aliased = inputSchema(Aliased.class); + Optional flat = inputSchema(Flat.class); + + // Assert: a primitive marked not required is optional too, as a missing one reads as zero. + assertThat(schema) + .hasValue( + object( + ImmutableMap.of("section_name", STRING, "content", STRING, "priority", INTEGER), + "section_name")); + assertThat(aliased).hasValue(object(ImmutableMap.of("quantity", INTEGER, "qty", INTEGER))); + assertThat(flat).isEmpty(); + } + + @Test + public void outputSchema_keysTheMapperAlwaysWrites_areRequiredEvenIfNullOrMarkedNotRequired() { + // Act + Optional note = outputSchema(Note.class); + Optional draft = outputSchema(Draft.class); + + // Assert + assertThat(note.orElseThrow().required()) + .hasValue(ImmutableList.of("title", "pages", "subtitle", "body", "footer")); + assertThat(draft.orElseThrow().required()) + .hasValue(ImmutableList.of("section_name", "content", "priority")); + } + + @Test + public void outputSchema_keysTheMapperMayOmit_areNotRequiredAndAConverterTakesAnyValue() { + // Act + Optional schema = outputSchema(Sparse.class); + Optional counted = outputSchema(Counted.class); + Optional sparseIn = inputSchema(Sparse.class); + + // Assert: an omitted key is never null, but NON_NULL still writes an empty Optional as null. + // An input port does not need a key the mapper may omit. + ImmutableMap properties = + ImmutableMap.of( + "text", STRING, + "count", INTEGER, + "model", STRING, + "label", nullable(STRING), + "code", ANY, + "plain", STRING); + assertThat(schema).hasValue(object(properties, "code", "plain")); + assertThat(sparseIn.orElseThrow().required()).hasValue(ImmutableList.of("code", "plain")); + assertThat(counted.orElseThrow().required()).hasValue(ImmutableList.of("count", "label")); + assertThat(counted.orElseThrow().properties().orElseThrow()) + .containsAtLeast("note", STRING, "tag", nullable(STRING)); + } + + @Test + public void inputSchema_keysTheMapperMayOmitOrNeverWrites_areNotRequired() { + // Arrange: an inclusion may omit each key of Counted and Lenient, though none can be null or + // empty, the mapper never writes Login's password or Greeting's name, and required = true + // binds only an unaliased creator parameter. + ImmutableMap, ImmutableList> expected = + ImmutableMap.of( + Counted.class, ImmutableList.of(), + Lenient.class, ImmutableList.of(), + Login.class, ImmutableList.of("user"), + Greeting.class, ImmutableList.of(), + MarkedField.class, ImmutableList.of(), + MarkedSetter.class, ImmutableList.of(), + AliasedMarked.class, ImmutableList.of()); + + // Act + ImmutableMap, ImmutableList> actual = + expected.keySet().stream() + .collect(toImmutableMap(type -> type, FunctionNodeTest::requiredInputKeys)); + + // Assert: an input needs only the keys the mapper always writes, which the port enforces. + assertThat(actual).isEqualTo(expected); + assertThat(inputSchema(Greeting.class).orElseThrow().properties().orElseThrow()) + .containsKey("name"); + assertThat(outputSchema(Lenient.class).orElseThrow().required()) + .hasValue(ImmutableList.of("id", "count", "order", "status")); + } + + @Test + public void of_inputMissingAKey_failsOnlyIfTheInferredSchemaRequiresIt() { + // Arrange: of these keys, the inferred schemas require only Login's user and Strict's id. + FunctionNode lenient = + FunctionNodes.of( + "lenient", Lenient.class, String.class, (ctx, in) -> in.count() + " " + in.order()); + FunctionNode login = + FunctionNodes.of( + "login", Login.class, String.class, (ctx, in) -> in.user() + " " + in.password()); + FunctionNode greeting = + FunctionNodes.of("greeting", Greeting.class, String.class, (ctx, in) -> in.text); + FunctionNode strict = + FunctionNodes.of("strict", Strict.class, String.class, (ctx, in) -> in.id()); + FunctionNode markedField = + FunctionNodes.of("marked_field", MarkedField.class, String.class, (ctx, in) -> in.id); + FunctionNode aliased = + FunctionNodes.of("aliased", AliasedMarked.class, Integer.class, (ctx, in) -> in.q()); + + // Act + RunResult lenientRun = run(chain(emitter("make", ImmutableMap.of()), lenient)); + RunResult loginRun = run(chain(emitter("make", ImmutableMap.of("user", "u")), login)); + RunResult greetingRun = run(chain(emitter("make", ImmutableMap.of()), greeting)); + RunResult strictRun = run(chain(emitter("make", ImmutableMap.of()), strict)); + RunResult markedFieldRun = run(chain(emitter("make", ImmutableMap.of()), markedField)); + RunResult aliasedRun = run(chain(emitter("make", ImmutableMap.of("qty", 7)), aliased)); + + // Assert: a key marked required is required even if nullable. + lenientRun.assertCompleted(); + assertThat(lenientRun.outputAt("wf@1/lenient@1")).isEqualTo("0 null"); + loginRun.assertCompleted(); + assertThat(loginRun.outputAt("wf@1/login@1")).isEqualTo("u null"); + greetingRun.assertCompleted(); + assertThat(greetingRun.outputAt("wf@1/greeting@1")).isEqualTo("hi null"); + markedFieldRun.assertCompleted(); + aliasedRun.assertCompleted(); + assertThat(aliasedRun.outputAt("wf@1/aliased@1")).isEqualTo(7); + assertThat(strict.inputSchema()) + .hasValue(object(ImmutableMap.of("id", nullable(STRING)), "id")); + assertThat(strictRun.errorMessage("NodeInputValidationException")) + .isEqualTo( + "validation error: input of node 'strict' does not match " + + Strict.class.getName() + + "."); + } + + @Test + public void schemas_nullableOnAFieldCreatorParameterOrRecordComponent_makeThePropertyNullable() { + // Act + Optional memo = outputSchema(Memo.class); + Optional signed = inputSchema(Signed.class); + Optional remark = outputSchema(Remark.class); + + // Assert: the getter or accessor the mapper calls is not annotated. + assertThat(memo).hasValue(object(ImmutableMap.of("note", nullable(STRING)), "note")); + assertThat(signed).hasValue(object(ImmutableMap.of("name", nullable(STRING)))); + assertThat(remark).hasValue(object(ImmutableMap.of("note", nullable(STRING)), "note")); + } + + @Test + public void inputSchema_nestedRecordEnumCollectionAndMap_describeEachLevel() { + // Act + Optional shipment = inputSchema(Shipment.class); + Optional list = inputSchema(new TypeReference>() {}); + Optional set = inputSchema(new TypeReference>() {}); + Optional objects = inputSchema(new TypeReference>() {}); + + // Assert + Schema status = Schema.builder().type(Type.Known.STRING).enum_("OPEN", "SHIPPED").build(); + Schema orders = Schema.builder().type(Type.Known.ARRAY).items(ORDER).build(); + ImmutableMap properties = + ImmutableMap.of( + "status", status, "order", ORDER, "history", orders, "byId", typed(Type.Known.OBJECT)); + assertThat(shipment).hasValue(object(properties, "status", "order", "history", "byId")); + assertThat(list).hasValue(orders); + assertThat(set).hasValue(orders); + assertThat(objects).hasValue(typed(Type.Known.ARRAY)); + } + + @Test + public void inputSchema_enums_listTheValuesTheMapperWrites() { + // Act + Optional color = inputSchema(Color.class); + Optional level = inputSchema(Level.class); + + // Assert: an enum written as a number has no string values to list, so it has no schema. + assertThat(color) + .hasValue(Schema.builder().type(Type.Known.STRING).enum_("red", "dark-blue").build()); + assertThat(level).isEmpty(); + } + + @Test + public void outputSchema_propertiesOfNoFixedJsonType_takeAnyValue() { + // Act + Optional irregular = outputSchema(Irregular.class); + Optional pair = outputSchema(Pair.class); + + // Assert: each property takes any value, but a list whose elements carry type ids is a list. + ImmutableMap properties = + ImmutableMap.builder() + .put("id", ANY) + .put("codes", ANY) + .put("count", ANY) + .put("typed", ANY) + .put("typedOrders", typed(Type.Known.ARRAY)) + .put("byId", ANY) + .put("tagged", ANY) + .put("catchall", ANY) + .put("twoCatchalls", ANY) + .put("flat", ANY) + .put("pair", ANY) + .put("ref", ANY) + .put("pet", ANY) + .put("level", ANY) + .put("at", ANY) + .put("raw", ANY) + .put("pets", typed(Type.Known.ARRAY)) + .buildOrThrow(); + assertThat(irregular).hasValue(object(properties, properties.keySet().toArray(String[]::new))); + assertThat(pair).isEmpty(); + } + + @Test + public void inputSchema_recursiveTypes_areEmptyUnlikeNestedGenerics() { + // Act + Optional tree = inputSchema(TreeNode.class); + Optional box = inputSchema(new TypeReference>>() {}); + + // Assert: the Box inside a Box> is another type, not a cycle. + Schema intBox = object(ImmutableMap.of("item", INTEGER), "item"); + assertThat(tree).isEmpty(); + assertThat(box).hasValue(object(ImmutableMap.of("item", intBox), "item")); + } + + @Test + public void of_contentPorts_inferContentsSchemaForBothPorts() { + // Act + FunctionNode node = + FunctionNodes.of("n", Content.class, Content.class, (ctx, content) -> content); + + // Assert: a returned Content is the output in its JSON form, which omits an unset key. + for (Schema schema : + ImmutableList.of(node.inputSchema().orElseThrow(), node.outputSchema().orElseThrow())) { + assertThat(schema.type()).hasValue(new Type(Type.Known.OBJECT)); + assertThat(schema.properties().orElseThrow()).containsEntry("role", STRING); + assertThat(schema.properties().orElseThrow().keySet()).containsExactly("parts", "role"); + assertThat(schema.required()).isEmpty(); + } + assertThat(node.inputSchemaInferred()).isTrue(); + } + + @Test + public void of_eventAndObjectPorts_inferASchemaOnlyForAnEventInput() { + // Act + FunctionNode node = + FunctionNodes.of("n", Event.class, Event.class, (ctx, event) -> event); + Optional object = inputSchema(Object.class); + + // Assert: an Event reads every key as optional, a returned Event is sent as it is, and an + // Object takes any JSON value. + Schema input = node.inputSchema().orElseThrow(); + assertThat(input.properties().orElseThrow()).containsEntry("author", STRING); + assertThat(input.required()).isEmpty(); + assertThat(node.inputSchemaInferred()).isTrue(); + assertThat(node.outputSchema()).isEmpty(); + assertThat(object).isEmpty(); + } + @Test public void builder_withoutABody_failsAtBuild() { FunctionNode.Builder builder = @@ -2027,6 +2620,36 @@ public void of_mapPortsWithAJsonValueKey_readTheKeyBack() { assertThat(result.outputAt("wf@1/read@1")).isEqualTo("t1"); } + private static Optional inputSchema(Class type) { + return FunctionNodes.of("n", type, Void.class, (ctx, input) -> null).inputSchema(); + } + + private static Optional inputSchema(TypeReference type) { + return FunctionNodes.of("n", type, Void.class, (ctx, input) -> null).inputSchema(); + } + + private static ImmutableList requiredInputKeys(Class type) { + return ImmutableList.copyOf( + inputSchema(type).orElseThrow().required().orElse(ImmutableList.of())); + } + + private static Optional outputSchema(Class type) { + return FunctionNodes.of("n", Void.class, type, (ctx, input) -> null).outputSchema(); + } + + private static Schema typed(Type.Known type) { + return Schema.builder().type(type).build(); + } + + private static Schema nullable(Schema schema) { + return schema.toBuilder().nullable(true).build(); + } + + private static Schema object(ImmutableMap properties, String... required) { + Schema.Builder object = Schema.builder().type(Type.Known.OBJECT).properties(properties); + return (required.length == 0 ? object : object.required(required)).build(); + } + /** Returns a workflow named {@code wf} that runs {@code nodes} in sequence from START. */ private static Workflow chain(Node... nodes) { List edges = new ArrayList<>();