Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -217,30 +217,28 @@ public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stre
public void onPartialResponse(String s) {
emitter.onNext(
LlmResponse.builder()
.content(Content.fromParts(Part.fromText(s)))
.content(
Content.builder().role("model").parts(Part.fromText(s)).build())
.partial(true)
.build());
}

@Override
public void onCompleteResponse(ChatResponse chatResponse) {
if (chatResponse.aiMessage().hasToolExecutionRequests()) {
AiMessage aiMessage = chatResponse.aiMessage();
toParts(aiMessage).stream()
.map(Part::functionCall)
.forEach(
functionCall -> {
functionCall.ifPresent(
function -> {
emitter.onNext(
LlmResponse.builder()
.content(
Content.fromParts(
Part.fromFunctionCall(
function.name().orElse(""),
function.args().orElse(Map.of()))))
.build());
});
});
// The chunks were partial, so one response with the whole turn ends it.
AiMessage aiMessage = chatResponse.aiMessage();
List<Part> parts = new ArrayList<>();
if (aiMessage.text() != null && !aiMessage.text().isEmpty()) {
parts.add(Part.fromText(aiMessage.text()));
}
if (aiMessage.hasToolExecutionRequests()) {
parts.addAll(toParts(aiMessage));
}
if (!parts.isEmpty()) {
emitter.onNext(
LlmResponse.builder()
.content(Content.builder().role("model").parts(parts).build())
.build());
}
emitter.onComplete();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ void testSimpleStreamingResponse() {
"",
responses
.blockingStream()
.filter(llmResponse -> !llmResponse.partial().orElse(false))
.map(llmResponse -> llmResponse.content().get().text())
.toList());

Expand Down Expand Up @@ -430,15 +431,30 @@ void testStreamingRunConfig() {
// when
List<Event> eventsHi = askAgentStreaming(agent, "Hi");
String responseToHi =
String.join("", eventsHi.stream().map(event -> event.content().get().text()).toList());
String.join(
"",
eventsHi.stream()
.filter(event -> !event.partial().orElse(false))
.map(event -> event.content().get().text())
.toList());

List<Event> eventsQubit = askAgentStreaming(agent, "Tell me about qubits");
String responseToQubit =
String.join("", eventsQubit.stream().map(event -> event.content().get().text()).toList());
String.join(
"",
eventsQubit.stream()
.filter(event -> !event.partial().orElse(false))
.map(event -> event.content().get().text())
.toList());

List<Event> eventsWeather = askAgentStreaming(agent, "What's the weather in Paris?");
String responseToWeather =
String.join("", eventsWeather.stream().map(Event::stringifyContent).toList());
String.join(
"",
eventsWeather.stream()
.filter(event -> !event.partial().orElse(false))
.map(Event::stringifyContent)
.toList());

// then

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,17 @@ void testGenerateContentWithStreamingChatModel() {
assertThat(responses).hasSize(5); // 4 partial responses + 1 function call

// Verify the partial responses
assertThat(responses.stream().map(response -> response.content().orElseThrow().role()))
.containsExactly(
Optional.of("model"),
Optional.of("model"),
Optional.of("model"),
Optional.of("model"),
Optional.of("model"));
assertThat(responses.subList(0, 4).stream().map(LlmResponse::partial))
.containsExactly(
Optional.of(true), Optional.of(true), Optional.of(true), Optional.of(true));
assertThat(responses.get(4).partial()).isEmpty();
assertThat(responses.get(0).content().orElseThrow().text()).isEqualTo("Hello");
assertThat(responses.get(1).content().orElseThrow().text()).isEqualTo(", how");
assertThat(responses.get(2).content().orElseThrow().text()).isEqualTo(" can I help");
Expand All @@ -369,12 +380,93 @@ void testGenerateContentWithStreamingChatModel() {
.get(0)
.functionCall()
.orElseThrow();
assertThat(functionCall.id()).hasValue("123");
assertThat(functionCall.name()).isEqualTo(Optional.of("getWeather"));
assertThat(functionCall.args().orElseThrow()).containsEntry("city", "Paris");

disposable.dispose();
}

@Test
@DisplayName("Should end a streamed text turn with its full text")
void testGenerateContentWithStreamingChatModel_endsWithTheFullText() {
final LlmRequest llmRequest =
LlmRequest.builder().contents(List.of(Content.fromParts(Part.fromText("Hello")))).build();
final List<LlmResponse> responses = new ArrayList<>();
final StreamingChatResponseHandler[] handlerRef = new StreamingChatResponseHandler[1];
doAnswer(
invocation -> {
handlerRef[0] = invocation.getArgument(1);
return null;
})
.when(streamingChatModel)
.chat(any(ChatRequest.class), any(StreamingChatResponseHandler.class));
final var disposable =
streamingLangChain4j.generateContent(llmRequest, true).subscribe(responses::add);

handlerRef[0].onPartialResponse("Par");
handlerRef[0].onPartialResponse("is");
final ChatResponse chatResponse = mock(ChatResponse.class);
when(chatResponse.aiMessage()).thenReturn(AiMessage.from("Paris"));
handlerRef[0].onCompleteResponse(chatResponse);

assertThat(responses).hasSize(3);
final LlmResponse last = responses.get(2);
assertThat(last.partial()).isEmpty();
assertThat(last.content().orElseThrow().role()).hasValue("model");
assertThat(last.content().orElseThrow().text()).isEqualTo("Paris");
disposable.dispose();
}

@Test
@DisplayName("Should end a streamed turn with its text and calls in one response")
void testGenerateContentWithStreamingChatModel_endsWithTextAndCallsInOneResponse() {
final LlmRequest llmRequest =
LlmRequest.builder().contents(List.of(Content.fromParts(Part.fromText("Hello")))).build();
final List<LlmResponse> responses = new ArrayList<>();
final StreamingChatResponseHandler[] handlerRef = new StreamingChatResponseHandler[1];
doAnswer(
invocation -> {
handlerRef[0] = invocation.getArgument(1);
return null;
})
.when(streamingChatModel)
.chat(any(ChatRequest.class), any(StreamingChatResponseHandler.class));
final var disposable =
streamingLangChain4j.generateContent(llmRequest, true).subscribe(responses::add);

handlerRef[0].onPartialResponse("Let me check.");
final AiMessage aiMessage =
AiMessage.builder()
.text("Let me check.")
.toolExecutionRequests(
List.of(
ToolExecutionRequest.builder()
.id("c1")
.name("getWeather")
.arguments("{\"city\":\"Paris\"}")
.build(),
ToolExecutionRequest.builder()
.id("c2")
.name("getWeather")
.arguments("{\"city\":\"Rome\"}")
.build()))
.build();
final ChatResponse chatResponse = mock(ChatResponse.class);
when(chatResponse.aiMessage()).thenReturn(aiMessage);
handlerRef[0].onCompleteResponse(chatResponse);

assertThat(responses).hasSize(2);
final LlmResponse last = responses.get(1);
assertThat(last.partial()).isEmpty();
final List<Part> parts = last.content().orElseThrow().parts().orElseThrow();
assertThat(parts).hasSize(3);
assertThat(parts.get(0).text()).hasValue("Let me check.");
assertThat(parts.subList(1, 3).stream().map(part -> part.functionCall().orElseThrow().id()))
.containsExactly(Optional.of("c1"), Optional.of("c2"));
disposable.dispose();
}

@Test
@DisplayName("Should pass configuration options to LangChain4j")
void testGenerateContentWithConfigOptions() {
Expand Down
153 changes: 152 additions & 1 deletion core/src/main/java/com/google/adk/SchemaUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,29 @@
package com.google.adk;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectReader;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.genai.types.Schema;
import com.google.genai.types.Type;
import java.math.BigInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import org.jspecify.annotations.Nullable;

/** Utility class for validating schemas. */
/** Utility class for reading JSON and validating values against schemas. */
public final class SchemaUtils {

private static final ObjectReader STRICT_JSON =
JsonBaseModel.getMapper()
.readerFor(Object.class)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);

private SchemaUtils() {} // Private constructor for utility class

/**
Expand Down Expand Up @@ -128,6 +140,68 @@ public static void validateMapOnSchema(Map<String, Object> args, Schema schema,
}
}

/**
* Parses model text against {@code schema} as one JSON value, or returns the text itself when
* that fails and the schema accepts a string. Blank text, and a JSON null the schema allows, give
* {@code null}.
*
* <p>This method is only for use by Agent Development Kit.
*
* @throws IllegalArgumentException if the text does not match the schema
*/
public static @Nullable Object parseModelOutput(String output, Schema schema) {
if (output.isBlank()) {
return null;
}
String reason;
try {
Object value = readStrictJson(output);
reason = mismatch(value, schema);
if (reason == null) {
return value;
}
} catch (JsonProcessingException e) {
reason = "it is not JSON";
}
if (acceptsString(schema)) {
return output;
}
// The reason names only parts of the schema, never the model's output.
throw new IllegalArgumentException(
"validation error: the output does not match its schema; " + reason + ".");
}

/**
* Returns whether {@code schema} accepts a string, directly or through an {@code anyOf}.
*
* <p>This method is only for use by Agent Development Kit.
*/
public static boolean acceptsString(Schema schema) {
return typeOf(schema) == Type.Known.STRING
|| schema.anyOf().orElse(ImmutableList.of()).stream().anyMatch(SchemaUtils::acceptsString);
}

/**
* Reads {@code text} as one JSON value, failing on trailing text.
*
* <p>This method is only for use by Agent Development Kit.
*
* @throws JsonProcessingException if the text is not one JSON value
*/
public static @Nullable Object readStrictJson(String text) throws JsonProcessingException {
return STRICT_JSON.readValue(text);
}

/**
* Returns why {@code value} does not match {@code schema}, or empty if it does. The reason names
* parts of the schema but never the value, which comes from the user.
*
* <p>This method is only for use by Agent Development Kit.
*/
public static Optional<String> findMismatch(@Nullable Object value, Schema schema) {
return Optional.ofNullable(mismatch(value, schema));
}

/**
* Validates an output string against a schema.
*
Expand All @@ -144,4 +218,81 @@ public static Map<String, Object> validateOutputSchema(String output, Schema sch
validateMapOnSchema(outputMap, schema, false);
return outputMap;
}

private static @Nullable String mismatch(@Nullable Object value, Schema schema) {
if (value == null && schema.nullable().orElse(false)) {
return null;
}
// Check maps before anyOf so each member is validated as an object with its required keys.
if (value instanceof Map<?, ?> map) {
return mapMismatch(map, schema);
}
List<Schema> anyOf = schema.anyOf().orElse(ImmutableList.of());
if (!anyOf.isEmpty() && anyOf.stream().allMatch(member -> mismatch(value, member) != null)) {
return "it matches none of the schema's anyOf alternatives";
}
Type.Known type = typeOf(schema);
if (type == null || type == Type.Known.TYPE_UNSPECIFIED) {
return null;
}
boolean matches =
switch (type) {
case STRING -> value instanceof String;
case INTEGER ->
value instanceof Integer
|| value instanceof Long
|| value instanceof Short
|| value instanceof Byte
|| value instanceof BigInteger;
case NUMBER -> value instanceof Number;
case BOOLEAN -> value instanceof Boolean;
case NULL -> value == null;
case ARRAY -> {
if (!(value instanceof Collection<?> items)) {
yield false;
}
Optional<Schema> itemSchema = schema.items();
yield itemSchema.isEmpty()
|| items.stream().allMatch(item -> mismatch(item, itemSchema.get()) == null);
}
case OBJECT -> false;
default -> true;
};
return matches ? null : "it is not of the schema's type " + type;
}

/** Returns why {@code map} does not match {@code schema}, or {@code null} if it does. */
private static @Nullable String mapMismatch(Map<?, ?> map, Schema schema) {
Type.Known type = typeOf(schema);
if (type != null && type != Type.Known.OBJECT && type != Type.Known.TYPE_UNSPECIFIED) {
return "the schema's type is " + type + ", not an object";
}
List<Schema> anyOf = schema.anyOf().orElse(ImmutableList.of());
if (!anyOf.isEmpty() && anyOf.stream().allMatch(member -> mapMismatch(map, member) != null)) {
return "it matches none of the schema's anyOf alternatives";
}
Optional<Map<String, Schema>> properties = schema.properties();
if (properties.isPresent()) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
Schema property = properties.get().get(String.valueOf(entry.getKey()));
// An undeclared key comes from the user, so the reason does not name it.
if (property == null) {
return "it has a key the schema does not declare";
}
if (mismatch(entry.getValue(), property) != null) {
return "property '" + entry.getKey() + "' does not match its schema";
}
}
}
for (String required : schema.required().orElse(ImmutableList.of())) {
if (!map.containsKey(required)) {
return "required property '" + required + "' is missing";
}
}
return null;
}

private static Type.@Nullable Known typeOf(Schema schema) {
return schema.type().map(Type::knownEnum).orElse(null);
}
}
Loading
Loading