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 @@ -45,7 +45,7 @@
class LangChain4jIntegrationTest {

public static final String CLAUDE_4_6_SONNET = "claude-sonnet-4-6";
public static final String GEMINI_2_0_FLASH = "gemini-2.0-flash";
public static final String GEMINI_3_6_FLASH = "gemini-3.6-flash";
public static final String GPT_4_O_MINI = "gpt-4o-mini";

@Test
Expand Down Expand Up @@ -174,7 +174,7 @@ void testAgentTool() {
LlmAgent.builder()
.name("weather-agent")
.description("Weather agent")
.model(GEMINI_2_0_FLASH)
.model(GEMINI_3_6_FLASH)
.instruction(
"""
Your role is to always answer that the weather is sunny and 20°C.
Expand Down Expand Up @@ -270,7 +270,7 @@ void testSubAgent() {
LlmAgent.builder()
.name("coordinator-agent")
.description("Coordinator agent")
.model(GEMINI_2_0_FLASH)
.model(GEMINI_3_6_FLASH)
.instruction(
"""
Your role is to coordinate 2 agents:
Expand Down Expand Up @@ -303,16 +303,16 @@ void testSubAgent() {
assertEquals(1, hiEvent1.functionCalls().size());
FunctionCall hiFunctionCall = hiEvent1.functionCalls().get(0);
assertTrue(hiFunctionCall.id().isPresent());
assertEquals(Optional.of("transferToAgent"), hiFunctionCall.name());
assertEquals(Optional.of(Map.of("agentName", "greeterAgent")), hiFunctionCall.args());
assertEquals(Optional.of("transfer_to_agent"), hiFunctionCall.name());
assertEquals(Optional.of(Map.of("agent_name", "greeterAgent")), hiFunctionCall.args());

Event hiEvent2 = hiEvents.get(1);
assertTrue(hiEvent2.content().isPresent());
assertFalse(hiEvent2.functionResponses().isEmpty());
assertEquals(1, hiEvent2.functionResponses().size());
FunctionResponse hiFunctionResponse = hiEvent2.functionResponses().get(0);
assertTrue(hiFunctionResponse.id().isPresent());
assertEquals(Optional.of("transferToAgent"), hiFunctionResponse.name());
assertEquals(Optional.of("transfer_to_agent"), hiFunctionResponse.name());
assertEquals(Optional.of(Map.of()), hiFunctionResponse.response()); // Empty map for response

Event hiEvent3 = hiEvents.get(2);
Expand All @@ -329,16 +329,16 @@ void testSubAgent() {
assertEquals(1, byeEvent1.functionCalls().size());
FunctionCall byeFunctionCall = byeEvent1.functionCalls().get(0);
assertTrue(byeFunctionCall.id().isPresent());
assertEquals(Optional.of("transferToAgent"), byeFunctionCall.name());
assertEquals(Optional.of(Map.of("agentName", "farewellAgent")), byeFunctionCall.args());
assertEquals(Optional.of("transfer_to_agent"), byeFunctionCall.name());
assertEquals(Optional.of(Map.of("agent_name", "farewellAgent")), byeFunctionCall.args());

Event byeEvent2 = byeEvents.get(1);
assertTrue(byeEvent2.content().isPresent());
assertFalse(byeEvent2.functionResponses().isEmpty());
assertEquals(1, byeEvent2.functionResponses().size());
FunctionResponse byeFunctionResponse = byeEvent2.functionResponses().get(0);
assertTrue(byeFunctionResponse.id().isPresent());
assertEquals(Optional.of("transferToAgent"), byeFunctionResponse.name());
assertEquals(Optional.of("transfer_to_agent"), byeFunctionResponse.name());
assertEquals(Optional.of(Map.of()), byeFunctionResponse.response()); // Empty map for response

Event byeEvent3 = byeEvents.get(2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.google.adk.models.LlmResponse;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.GenerateContentResponseUsageMetadata;
import com.google.genai.types.Part;
import java.net.URI;
Expand Down Expand Up @@ -54,15 +55,13 @@
* <ul>
* <li>Text content in all message types
* <li>Tool/function calls in assistant messages
* <li>Tool/function responses in user messages
* <li>System instructions and configuration options
* </ul>
*
* <p>Note: Media attachments and tool responses are currently not supported due to Spring AI 1.1.0
* API limitations (protected/private constructors). These will be added once Spring AI provides
* public APIs for these features.
*/
public class MessageConverter {

private static final String THOUGHT_SIGNATURES_METADATA_KEY = "thoughtSignatures";
private static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE =
new TypeReference<>() {};

Expand Down Expand Up @@ -261,10 +260,17 @@ private List<Message> handleUserContent(Content content) {
if (part.text().isPresent()) {
textBuilder.append(part.text().get());
} else if (part.functionResponse().isPresent()) {
// TODO: Spring AI 1.1.0 ToolResponseMessage constructors are protected
// For now, we skip tool responses in user messages
// This will need to be addressed in a future update when Spring AI provides
// a public API for creating ToolResponseMessage
FunctionResponse functionResponse = part.functionResponse().get();
ToolResponseMessage.ToolResponse toolResponse =
new ToolResponseMessage.ToolResponse(
functionResponse.id().orElse(""),
functionResponse
.name()
.orElseThrow(
() -> new IllegalStateException("Function response name is missing")),
toJson(functionResponse.response().orElse(Map.of())));
toolResponseMessages.add(
ToolResponseMessage.builder().responses(List.of(toolResponse)).build());
} else if (part.inlineData().isPresent()) {
// Handle inline media data (images, audio, video, etc.)
com.google.genai.types.Blob blob = part.inlineData().get();
Expand Down Expand Up @@ -298,21 +304,25 @@ private List<Message> handleUserContent(Content content) {
}

List<Message> messages = new ArrayList<>();
messages.add(UserMessage.builder().text(textBuilder.toString()).media(mediaList).build());
messages.addAll(toolResponseMessages);
if (toolResponseMessages.isEmpty() || textBuilder.length() > 0 || !mediaList.isEmpty()) {
messages.add(UserMessage.builder().text(textBuilder.toString()).media(mediaList).build());
}

return messages;
}

private AssistantMessage handleAssistantContent(Content content) {
StringBuilder textBuilder = new StringBuilder();
List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>();
List<byte[]> thoughtSignatures = new ArrayList<>();

for (Part part : content.parts().orElse(List.of())) {
if (part.text().isPresent()) {
textBuilder.append(part.text().get());
} else if (part.functionCall().isPresent()) {
FunctionCall functionCall = part.functionCall().get();
part.thoughtSignature().ifPresent(thoughtSignatures::add);
toolCalls.add(
new AssistantMessage.ToolCall(
functionCall
Expand All @@ -330,7 +340,12 @@ private AssistantMessage handleAssistantContent(Content content) {
if (toolCalls.isEmpty()) {
return new AssistantMessage(text);
} else {
return AssistantMessage.builder().content(text).toolCalls(toolCalls).build();
AssistantMessage.Builder<?> builder =
AssistantMessage.builder().content(text).toolCalls(toolCalls);
if (!thoughtSignatures.isEmpty()) {
builder.properties(Map.of(THOUGHT_SIGNATURES_METADATA_KEY, thoughtSignatures));
}
return builder.build();
}
}

Expand Down Expand Up @@ -434,6 +449,8 @@ private boolean isTurnCompleteResponse(ChatResponse response) {

private Content convertAssistantMessageToContent(AssistantMessage assistantMessage) {
List<Part> parts = new ArrayList<>();
List<byte[]> thoughtSignatures = getThoughtSignatures(assistantMessage);
int thoughtSignatureIndex = 0;

// Add text content
if (assistantMessage.getText() != null && !assistantMessage.getText().isEmpty()) {
Expand All @@ -451,8 +468,13 @@ private Content convertAssistantMessageToContent(AssistantMessage assistantMessa
FunctionCall functionCall =
FunctionCall.builder().id(toolCall.id()).name(toolCall.name()).args(args).build();

// Create Part with the FunctionCall (preserves ID)
parts.add(Part.builder().functionCall(functionCall).build());
// Preserve Gemini thought signatures alongside their corresponding function calls.
Part.Builder partBuilder = Part.builder().functionCall(functionCall);
if (thoughtSignatureIndex < thoughtSignatures.size()) {
partBuilder.thoughtSignature(thoughtSignatures.get(thoughtSignatureIndex));
thoughtSignatureIndex++;
}
parts.add(partBuilder.build());
} catch (JsonProcessingException e) {
throw MessageConversionException.jsonParsingFailed("tool call arguments", e);
}
Expand All @@ -462,6 +484,14 @@ private Content convertAssistantMessageToContent(AssistantMessage assistantMessa
return Content.builder().role("model").parts(parts).build();
}

private List<byte[]> getThoughtSignatures(AssistantMessage assistantMessage) {
Object value = assistantMessage.getMetadata().get(THOUGHT_SIGNATURES_METADATA_KEY);
if (!(value instanceof List<?> values)) {
return List.of();
}
return values.stream().filter(byte[].class::isInstance).map(byte[].class::cast).toList();
}

private String toJson(Object object) {
try {
return objectMapper.writeValueAsString(object);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
Expand Down Expand Up @@ -119,6 +120,7 @@ void testToLlmPromptWithAssistantMessage() {

@Test
void testToLlmPromptWithFunctionCall() {
byte[] thoughtSignature = {1, 2, 3};
FunctionCall functionCall =
FunctionCall.builder()
.name("get_weather")
Expand All @@ -127,7 +129,8 @@ void testToLlmPromptWithFunctionCall() {
.build();

// Create Part with FunctionCall inside using Part.builder
Part functionCallPart = Part.builder().functionCall(functionCall).build();
Part functionCallPart =
Part.builder().functionCall(functionCall).thoughtSignature(thoughtSignature).build();

Content assistantContent =
Content.builder()
Expand All @@ -151,17 +154,12 @@ void testToLlmPromptWithFunctionCall() {
assertThat(toolCall.id()).isEqualTo("call_123"); // ID should be preserved now
assertThat(toolCall.name()).isEqualTo("get_weather");
assertThat(toolCall.type()).isEqualTo("function");
assertThat(assistantMessage.getMetadata().get("thoughtSignatures"))
.isEqualTo(List.of(thoughtSignature));
}

@Test
void testToLlmPromptWithFunctionResponse() {
// TODO: This test is currently limited due to Spring AI 1.1.0 API constraints
// ToolResponseMessage constructors are protected, so function responses are skipped
// Once Spring AI provides public APIs, this test should be updated to verify:
// 1. ToolResponseMessage is created
// 2. Tool response data is properly converted
// 3. Tool call IDs are preserved

FunctionResponse functionResponse =
FunctionResponse.builder()
.name("get_weather")
Expand All @@ -174,29 +172,27 @@ void testToLlmPromptWithFunctionResponse() {
.role("user")
.parts(
Part.fromText("What's the weather?"),
Part.fromFunctionResponse(
functionResponse.name().orElse(""),
functionResponse.response().orElse(Map.of())))
Part.builder().functionResponse(functionResponse).build())
.build();

LlmRequest request = LlmRequest.builder().contents(List.of(userContent)).build();

Prompt prompt = messageConverter.toLlmPrompt(request);

// Currently only UserMessage is created (function response is skipped)
assertThat(prompt.getInstructions()).hasSize(1);
assertThat(prompt.getInstructions()).hasSize(2);

Message userMessage = prompt.getInstructions().get(0);
Message toolResponseMessage = prompt.getInstructions().get(0);
assertThat(toolResponseMessage).isInstanceOf(ToolResponseMessage.class);
ToolResponseMessage toolResponse = (ToolResponseMessage) toolResponseMessage;
assertThat(toolResponse.getResponses()).hasSize(1);
ToolResponseMessage.ToolResponse response = toolResponse.getResponses().get(0);
assertThat(response.id()).isEqualTo("call_123");
assertThat(response.name()).isEqualTo("get_weather");
assertThat(response.responseData()).contains("temperature", "72°F", "condition", "sunny");

Message userMessage = prompt.getInstructions().get(1);
assertThat(userMessage).isInstanceOf(UserMessage.class);
assertThat(((UserMessage) userMessage).getText()).isEqualTo("What's the weather?");

// When Spring AI provides public API for ToolResponseMessage, uncomment:
// Message toolResponseMessage = prompt.getInstructions().get(1);
// assertThat(toolResponseMessage).isInstanceOf(ToolResponseMessage.class);
// ToolResponseMessage toolResponse = (ToolResponseMessage) toolResponseMessage;
// assertThat(toolResponse.getResponses()).hasSize(1);
// ToolResponseMessage.ToolResponse response = toolResponse.getResponses().get(0);
// assertThat(response.name()).isEqualTo("get_weather");
}

@Test
Expand All @@ -217,13 +213,15 @@ void testToLlmResponseFromChatResponse() {

@Test
void testToLlmResponseFromChatResponseWithToolCalls() {
byte[] thoughtSignature = {1, 2, 3};
AssistantMessage.ToolCall toolCall =
new AssistantMessage.ToolCall(
"call_123", "function", "get_weather", "{\"location\":\"San Francisco\"}");

AssistantMessage assistantMessage =
AssistantMessage.builder()
.content("Let me check the weather.")
.properties(Map.of("thoughtSignatures", List.of(thoughtSignature)))
.toolCalls(List.of(toolCall))
.build();

Expand All @@ -245,6 +243,7 @@ void testToLlmResponseFromChatResponseWithToolCalls() {
assertThat(functionCallPart.functionCall().get().name()).contains("get_weather");
// Verify ID is preserved
assertThat(functionCallPart.functionCall().get().id()).contains("call_123");
assertThat(functionCallPart.thoughtSignature()).contains(thoughtSignature);
}

@Test
Expand Down
22 changes: 16 additions & 6 deletions core/src/main/java/com/google/adk/agents/BaseAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -331,16 +331,26 @@ private Flowable<Event> run(
},
agentInvocation -> {
InvocationContext invocationContext = agentInvocation.getCtx();
if (invocationContext.isCancellationRequested()) {
return Flowable.empty();
}
Flowable<Event> mainAndAfterEvents =
Flowable.defer(() -> runImplementation.apply(invocationContext))
Flowable.defer(
() ->
invocationContext.isCancellationRequested()
? Flowable.empty()
: runImplementation.apply(invocationContext))
.concatWith(
Flowable.defer(
() ->
callCallback(
afterCallbacksToFunctions(
invocationContext.pluginManager(), afterAgentCallback),
invocationContext)
.toFlowable()));
invocationContext.isCancellationRequested()
? Flowable.empty()
: callCallback(
afterCallbacksToFunctions(
invocationContext.pluginManager(),
afterAgentCallback),
invocationContext)
.toFlowable()));

return callCallback(
beforeCallbacksToFunctions(
Expand Down
43 changes: 43 additions & 0 deletions core/src/main/java/com/google/adk/agents/CancellationToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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
*
* https://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.agents;

import io.reactivex.rxjava3.core.Completable;

/** A thread-safe, cooperative cancellation signal for an agent invocation. */
@FunctionalInterface
public interface CancellationToken {
/** Returns whether cancellation has been requested. */
boolean isCancellationRequested();

/** Completes when cancellation is requested, or never for polling-only token implementations. */
default Completable onCancellation() {
return Completable.never();
}

/** Returns a token that is never cancelled. */
static CancellationToken none() {
return NeverCancelledHolder.INSTANCE;
}

/** Holder for the shared no-op token. */
final class NeverCancelledHolder {
private static final CancellationToken INSTANCE = () -> false;

private NeverCancelledHolder() {}
}
}
Loading