From 4e358b4e9fffcba823bcd8bcccbc5b6cc7f268c0 Mon Sep 17 00:00:00 2001 From: Mateusz Krawiec Date: Tue, 10 Nov 2026 05:14:58 -0800 Subject: [PATCH] fix(langchain4j): accept free-form schemas in tool declarations PiperOrigin-RevId: 1018130280 --- .../adk/models/langchain4j/LangChain4j.java | 124 +++++-- .../models/langchain4j/LangChain4jTest.java | 327 ++++++++++++++++++ 2 files changed, 426 insertions(+), 25 deletions(-) diff --git a/contrib/langchain4j/src/main/java/com/google/adk/models/langchain4j/LangChain4j.java b/contrib/langchain4j/src/main/java/com/google/adk/models/langchain4j/LangChain4j.java index fde730dee..dd3ded4fb 100644 --- a/contrib/langchain4j/src/main/java/com/google/adk/models/langchain4j/LangChain4j.java +++ b/contrib/langchain4j/src/main/java/com/google/adk/models/langchain4j/LangChain4j.java @@ -59,6 +59,7 @@ import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.chat.request.ToolChoice; +import dev.langchain4j.model.chat.request.json.JsonAnyOfSchema; import dev.langchain4j.model.chat.request.json.JsonArraySchema; import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; @@ -564,8 +565,18 @@ private List toToolSpecifications(LlmRequest llmRequest) { } private JsonObjectSchema toParameters(Schema schema) { - if (schema.type().isPresent() && Type.Known.OBJECT.equals(schema.type().get().knownEnum())) { + // An untyped root with anyOf or items describes something other than an object. + if (schema.type().isEmpty() + && schema.properties().isEmpty() + && (schema.anyOf().map(branches -> !branches.isEmpty()).orElse(false) + || schema.items().isPresent())) { + throw new UnsupportedOperationException( + "LangChain4jLlm does not support an untyped root schema with anyOf or items"); + } + // Any other untyped root describes an object. + if (schema.type().map(t -> t.knownEnum() == Type.Known.OBJECT).orElse(true)) { return JsonObjectSchema.builder() + .description(schema.description().orElse(null)) .addProperties(toProperties(schema)) .required(schema.required().orElse(List.of())) .build(); @@ -582,31 +593,94 @@ private Map toProperties(Schema schema) { return result; } - private JsonSchemaElement toJsonSchemaElement(Schema schema) { - if (schema != null && schema.type().isPresent()) { - Type type = schema.type().get(); - return switch (type.knownEnum()) { - case STRING -> - JsonStringSchema.builder().description(schema.description().orElse(null)).build(); - case NUMBER -> - JsonNumberSchema.builder().description(schema.description().orElse(null)).build(); - case INTEGER -> - JsonIntegerSchema.builder().description(schema.description().orElse(null)).build(); - case BOOLEAN -> - JsonBooleanSchema.builder().description(schema.description().orElse(null)).build(); - case ARRAY -> - JsonArraySchema.builder() - .description(schema.description().orElse(null)) - .items(toJsonSchemaElement(schema.items().orElseThrow())) - .build(); - case OBJECT -> toParameters(schema); - default -> - throw new UnsupportedFeatureException( - "LangChain4jLlm does not support schema of type: " + type); - }; - } else { - throw new IllegalArgumentException("Schema type cannot be null or absent"); + private JsonSchemaElement toJsonSchemaElement(@Nullable Schema schema) { + if (schema == null) { + return anyValue(null); } + // An untyped schema maps its anyOf branches; with properties or required keys it is an object, + // with items an array, as in SchemaUtils; otherwise it accepts any value. + boolean declaresKeys = schema.properties().isPresent() || schema.required().isPresent(); + if (schema.type().isEmpty()) { + if (schema.anyOf().map(branches -> !branches.isEmpty()).orElse(false)) { + return toAnyOf(schema); + } + if (!declaresKeys && schema.items().isEmpty()) { + return anyValue(schema.description().orElse(null)); + } + } + Type.Known type = + schema + .type() + .map(Type::knownEnum) + .orElse(declaresKeys ? Type.Known.OBJECT : Type.Known.ARRAY); + return switch (type) { + case STRING -> + JsonStringSchema.builder().description(schema.description().orElse(null)).build(); + case NUMBER -> + JsonNumberSchema.builder().description(schema.description().orElse(null)).build(); + case INTEGER -> + JsonIntegerSchema.builder().description(schema.description().orElse(null)).build(); + case BOOLEAN -> + JsonBooleanSchema.builder().description(schema.description().orElse(null)).build(); + case ARRAY -> + JsonArraySchema.builder() + .description(schema.description().orElse(null)) + .items(schema.items().map(this::toJsonSchemaElement).orElseGet(() -> anyValue(null))) + .build(); + case OBJECT -> toParameters(schema); + default -> + throw new UnsupportedFeatureException( + "LangChain4jLlm does not support schema of type: " + type); + }; + } + + /** + * Maps the anyOf branches of {@code schema}, without NULL branches, which LangChain4j tool + * parameters cannot express. A single remaining branch is returned as itself. + */ + private JsonSchemaElement toAnyOf(Schema schema) { + @Nullable String description = schema.description().orElse(null); + List branches = new ArrayList<>(); + for (Schema branch : schema.anyOf().get()) { + if (branch.type().map(t -> t.knownEnum() != Type.Known.NULL).orElse(true)) { + branches.add(branch); + } + } + if (branches.isEmpty()) { + return anyValue(description); + } + if (branches.size() == 1) { + Schema only = branches.get(0); + if (description != null && only.description().isEmpty()) { + only = only.toBuilder().description(description).build(); + } + return toJsonSchemaElement(only); + } + List elements = new ArrayList<>(); + for (Schema branch : branches) { + elements.add(toJsonSchemaElement(branch)); + } + return JsonAnyOfSchema.builder().description(description).anyOf(elements).build(); + } + + /** + * Returns an element that accepts a string, number, boolean, object, or an array of those, for a + * schema without a type. Not a {@code JsonRawSchema}, which the Gemini provider rejects. The + * array branch has items, which Gemini requires. + */ + private static JsonSchemaElement anyValue(@Nullable String description) { + List nonArrays = + List.of( + new JsonStringSchema(), + new JsonNumberSchema(), + new JsonBooleanSchema(), + JsonObjectSchema.builder().build()); + List anyOf = new ArrayList<>(nonArrays); + anyOf.add( + JsonArraySchema.builder() + .items(JsonAnyOfSchema.builder().anyOf(nonArrays).build()) + .build()); + return JsonAnyOfSchema.builder().description(description).anyOf(anyOf).build(); } private LlmResponse toLlmResponse(ChatResponse chatResponse, ChatRequest chatRequest) { diff --git a/contrib/langchain4j/src/test/java/com/google/adk/models/langchain4j/LangChain4jTest.java b/contrib/langchain4j/src/test/java/com/google/adk/models/langchain4j/LangChain4jTest.java index 7ae5fc061..05f44b614 100644 --- a/contrib/langchain4j/src/test/java/com/google/adk/models/langchain4j/LangChain4jTest.java +++ b/contrib/langchain4j/src/test/java/com/google/adk/models/langchain4j/LangChain4jTest.java @@ -15,11 +15,13 @@ */ package com.google.adk.models.langchain4j; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.agents.LlmAgent; import com.google.adk.agents.RunConfig; @@ -28,7 +30,9 @@ import com.google.adk.runner.InMemoryRunner; import com.google.adk.sessions.Session; import com.google.adk.tools.FunctionTool; +import com.google.adk.tools.SetModelResponseTool; import com.google.genai.types.*; +import com.sun.net.httpserver.HttpServer; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.UserMessage; @@ -37,17 +41,26 @@ import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.request.json.JsonAnyOfSchema; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; import dev.langchain4j.model.chat.request.json.JsonStringSchema; import dev.langchain4j.model.chat.response.ChatResponse; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; import dev.langchain4j.model.output.TokenUsage; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.subscribers.TestSubscriber; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -57,6 +70,25 @@ class LangChain4jTest { private static final String MODEL_NAME = "test-model"; + /** + * An output schema with an untyped property, an array without items, and an untyped object with + * properties. + */ + private static final Schema FREE_FORM_SCHEMA = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "extra", + Schema.builder().description("Anything").build(), + "tags", + Schema.builder().type("ARRAY").build(), + "nested", + Schema.builder() + .properties(Map.of("a", Schema.builder().type("STRING").build())) + .build())) + .build(); + private ChatModel chatModel; private StreamingChatModel streamingChatModel; private LangChain4j langChain4j; @@ -977,6 +1009,301 @@ void testGenerateContentWithMcpToolParametersJsonSchemaAsSchema() { assertThat(capturedRequest.toolSpecifications().get(0).description()).isEqualTo("An MCP tool"); } + @Test + @DisplayName("Should send an empty MCP input schema as an open object") + void testGenerateContentWithMcpToolEmptyInputSchema() { + // When + final JsonObjectSchema parameters = sentToolParameters(Map.of(), null); + + // Then + assertThat(parameters.properties()).isEmpty(); + assertThat(parameters.required()).isEmpty(); + } + + @Test + @DisplayName("Should send untyped parameters with only required fields as an object") + void testGenerateContentWithUntypedRequiredOnlyParameters() { + // When + final JsonObjectSchema parameters = + sentToolParameters(null, Schema.builder().required(List.of("id")).build()); + + // Then + assertThat(parameters.properties()).isEmpty(); + assertThat(parameters.required()).containsExactly("id"); + } + + @Test + @DisplayName("Should send an optional MCP string parameter as a string") + void testGenerateContentWithMcpToolOptionalStringParameter() { + // Given + final Map inputSchema = + Map.of( + "type", + "object", + "properties", + Map.of( + "city", + Map.of( + "description", + "City name", + "anyOf", + List.of(Map.of("type", "string"), Map.of("type", "null"))))); + + // When + final JsonObjectSchema parameters = sentToolParameters(inputSchema, null); + + // Then + assertThat(parameters.properties().get("city")) + .isEqualTo(JsonStringSchema.builder().description("City name").build()); + } + + @Test + @DisplayName("Should send an untyped anyOf of two types as anyOf") + void testGenerateContentWithUntypedAnyOfParameter() { + // Given + final Schema parameters = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "id", + Schema.builder() + .anyOf( + List.of( + Schema.builder().type("STRING").build(), + Schema.builder().type("INTEGER").build())) + .build())) + .build(); + + // When + final JsonObjectSchema sent = sentToolParameters(null, parameters); + + // Then + assertThat(sent.properties().get("id")) + .isEqualTo( + JsonAnyOfSchema.builder() + .anyOf(List.of(new JsonStringSchema(), new JsonIntegerSchema())) + .build()); + } + + @Test + @DisplayName("Should keep the description of a nested object parameter") + void testGenerateContentWithNestedObjectDescription() { + // Given + final Schema parameters = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "address", + Schema.builder() + .type("OBJECT") + .description("Where to deliver") + .properties(Map.of("street", Schema.builder().type("STRING").build())) + .build())) + .build(); + + // When + final JsonObjectSchema sent = sentToolParameters(null, parameters); + + // Then + assertThat(((JsonObjectSchema) sent.properties().get("address")).description()) + .isEqualTo("Where to deliver"); + } + + @Test + @DisplayName("Should reject an untyped root schema with anyOf") + void testGenerateContentWithUntypedAnyOfRootThrows() { + // Given + final Schema parameters = + Schema.builder() + .anyOf( + List.of( + Schema.builder().type("STRING").build(), + Schema.builder().type("INTEGER").build())) + .build(); + + // When / Then + assertThrows(UnsupportedOperationException.class, () -> sentToolParameters(null, parameters)); + } + + @Test + @DisplayName("Should reject an untyped root schema with items") + void testGenerateContentWithUntypedItemsRootThrows() { + // Given + final Schema parameters = + Schema.builder().items(Schema.builder().type("STRING").build()).build(); + + // When / Then + assertThrows(UnsupportedOperationException.class, () -> sentToolParameters(null, parameters)); + } + + @Test + @DisplayName("Should send a nested untyped schema with only required keys as an object") + void testGenerateContentWithNestedUntypedRequiredOnlyParameter() { + // Given + final Schema parameters = + Schema.builder() + .type("OBJECT") + .properties(Map.of("item", Schema.builder().required(List.of("id")).build())) + .build(); + + // When + final JsonObjectSchema sent = sentToolParameters(null, parameters); + + // Then + assertThat(sent.properties().get("item")) + .isEqualTo(JsonObjectSchema.builder().required(List.of("id")).build()); + } + + @Test + @DisplayName("Should send a nested untyped schema with only items as an array") + void testGenerateContentWithNestedUntypedItemsOnlyParameter() { + // Given + final Schema parameters = + Schema.builder() + .type("OBJECT") + .properties( + Map.of( + "tags", + Schema.builder() + .description("Tags") + .items(Schema.builder().type("STRING").build()) + .build())) + .build(); + + // When + final JsonObjectSchema sent = sentToolParameters(null, parameters); + + // Then + assertThat(sent.properties().get("tags")) + .isEqualTo( + JsonArraySchema.builder().description("Tags").items(new JsonStringSchema()).build()); + } + + /** + * Returns the parameters sent for a tool declared with {@code parametersJsonSchema} or, when that + * is null, with {@code parameters}. + */ + private JsonObjectSchema sentToolParameters(Object parametersJsonSchema, Schema parameters) { + final com.google.adk.tools.BaseTool tool = mock(com.google.adk.tools.BaseTool.class); + when(tool.name()).thenReturn("lookup"); + when(tool.description()).thenReturn("Looks something up"); + final FunctionDeclaration functionDeclaration = mock(FunctionDeclaration.class); + when(tool.declaration()).thenReturn(Optional.of(functionDeclaration)); + when(functionDeclaration.parametersJsonSchema()) + .thenReturn(Optional.ofNullable(parametersJsonSchema)); + when(functionDeclaration.parameters()).thenReturn(Optional.ofNullable(parameters)); + final ChatResponse chatResponse = mock(ChatResponse.class); + when(chatResponse.aiMessage()).thenReturn(AiMessage.from("Done")); + when(chatModel.chat(any(ChatRequest.class))).thenReturn(chatResponse); + final LlmRequest llmRequest = + LlmRequest.builder() + .contents(List.of(Content.fromParts(Part.fromText("Look it up")))) + .tools(Map.of("lookup", tool)) + .build(); + + langChain4j.generateContent(llmRequest, false).blockingFirst(); + + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ChatRequest.class); + verify(chatModel).chat(requestCaptor.capture()); + return requestCaptor.getValue().toolSpecifications().get(0).parameters(); + } + + @Test + @DisplayName("Should convert free-form schemas in tool parameters") + void testGenerateContentWithFreeFormToolParameters() { + // Given + final ChatResponse chatResponse = mock(ChatResponse.class); + when(chatResponse.aiMessage()).thenReturn(AiMessage.from("Done")); + when(chatModel.chat(any(ChatRequest.class))).thenReturn(chatResponse); + + // When + final LlmResponse response = + langChain4j.generateContent(freeFormSetModelResponseRequest(), false).blockingFirst(); + + // Then + assertThat(response.content().get().text()).isEqualTo("Done"); + final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ChatRequest.class); + verify(chatModel).chat(requestCaptor.capture()); + final Map properties = + requestCaptor.getValue().toolSpecifications().get(0).parameters().properties(); + final JsonAnyOfSchema extra = (JsonAnyOfSchema) properties.get("extra"); + assertThat(extra.description()).isEqualTo("Anything"); + assertThat(extra.anyOf()).hasSize(5); + assertThat(((JsonArraySchema) properties.get("tags")).items()) + .isInstanceOf(JsonAnyOfSchema.class); + assertThat(((JsonObjectSchema) properties.get("nested")).properties().get("a")) + .isInstanceOf(JsonStringSchema.class); + } + + @Test + @DisplayName("Should send free-form tool parameters through the Gemini provider") + void testGenerateContentWithFreeFormToolParameters_geminiProvider() throws Exception { + // Given + final AtomicReference requestBody = new AtomicReference<>(); + final HttpServer server = + HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext( + "/", + exchange -> { + requestBody.set(new String(exchange.getRequestBody().readAllBytes(), UTF_8)); + final byte[] reply = + ("{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Done\"}]}," + + "\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1," + + "\"candidatesTokenCount\":1,\"totalTokenCount\":2}}") + .getBytes(UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, reply.length); + try (OutputStream body = exchange.getResponseBody()) { + body.write(reply); + } + }); + + // When + final LlmResponse response; + server.start(); + try { + final LangChain4j gemini = + LangChain4j.builder() + .chatModel( + GoogleAiGeminiChatModel.builder() + .apiKey("test-key") + .modelName("gemini-2.5-flash") + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) + .maxRetries(0) + .build()) + .build(); + response = gemini.generateContent(freeFormSetModelResponseRequest(), false).blockingFirst(); + } finally { + server.stop(0); + } + + // Then + assertThat(response.content().get().text()).isEqualTo("Done"); + // LangChain4j 1.12 sends "tools" as one object, later versions as a list of them. + final JsonNode tools = new ObjectMapper().readTree(requestBody.get()).path("tools"); + final JsonNode properties = + (tools.isArray() ? tools.path(0) : tools) + .at("/functionDeclarations/0/parameters/properties"); + assertThat(properties.isObject()).isTrue(); + assertThat(properties.at("/extra/anyOf").size()).isEqualTo(5); + assertThat(properties.at("/nested/properties/a/type").asText()).isEqualToIgnoringCase("string"); + // Gemini rejects an array schema without items. + assertThat( + properties.findParents("type").stream() + .filter(node -> node.get("type").asText().equalsIgnoreCase("array"))) + .isNotEmpty() + .allMatch(node -> node.has("items")); + } + + private static LlmRequest freeFormSetModelResponseRequest() { + return LlmRequest.builder() + .contents(List.of(Content.fromParts(Part.fromText("Answer")))) + .tools(Map.of(SetModelResponseTool.NAME, new SetModelResponseTool(FREE_FORM_SCHEMA))) + .build(); + } + @Test @DisplayName( "Should use TokenCountEstimator to estimate token usage when TokenUsage is not available")