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
29 changes: 29 additions & 0 deletions core/src/main/java/com/google/adk/agents/AgentMode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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.agents;

import com.google.adk.annotations.Experimental;

/** How an {@link LlmAgent} converses when it runs as a workflow node. */
@Experimental
public enum AgentMode {
/**
* Answers one input with one response, without conversing with the user. The node does not yet
* give its response as its output.
*/
SINGLE_TURN,
}
8 changes: 7 additions & 1 deletion core/src/main/java/com/google/adk/agents/BaseAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -509,8 +509,14 @@ private Event checkpointEvent(InvocationContext context, EventActions actions) {
@Override
public Flowable<Event> runNode(
com.google.adk.agents.Context context, @Nullable Object nodeInput) {
return runAsNode(context, context.invocationContext());
}

/** Runs this agent as {@link #runNode} does, but on {@code invocationContext}. */
final Flowable<Event> runAsNode(
com.google.adk.agents.Context context, InvocationContext invocationContext) {
NodeExecutionState nodeState = context.requireNodeState();
return runWithCallbacks(context.invocationContext(), /* keepBranch= */ true, this::runAsyncImpl)
return runWithCallbacks(invocationContext, /* keepBranch= */ true, this::runAsyncImpl)
.doOnNext(
event -> {
if (!isNullOrEmpty(event.author())) {
Expand Down
31 changes: 31 additions & 0 deletions core/src/main/java/com/google/adk/agents/InvocationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public class InvocationContext {
@Nullable private String branch;
private BaseAgent agent;
private final @Nullable Node node;
private final @Nullable AgentModeOverride agentModeOverride;
private boolean endInvocation;

protected InvocationContext(Builder builder) {
Expand All @@ -100,6 +101,7 @@ protected InvocationContext(Builder builder) {
this.invocationId = builder.resolveInvocationId();
this.agent = builder.agent;
this.node = builder.node;
this.agentModeOverride = builder.agentModeOverride;
this.session = builder.session;
this.userContent = builder.userContent;
this.runConfig = builder.runConfig;
Expand Down Expand Up @@ -194,6 +196,19 @@ public Optional<Node> node() {
return Optional.ofNullable(node);
}

/**
* Returns the mode {@code agent} runs in as a workflow node in this context, or empty when it
* runs without one.
*
* <p>This method is only for use by Agent Development Kit.
*/
@Experimental
public Optional<AgentMode> agentMode(BaseAgent agent) {
return agentModeOverride != null && agentModeOverride.agent().equals(agent)
? Optional.of(agentModeOverride.mode())
: Optional.empty();
}

/** Returns the session associated with this invocation. */
public Session session() {
return session;
Expand Down Expand Up @@ -650,6 +665,11 @@ public int hashCode() {
}
}

/**
* The mode one agent runs in, keyed to that agent so the sub-agents it reaches run without it.
*/
private record AgentModeOverride(BaseAgent agent, AgentMode mode) {}

/** Builder for {@link InvocationContext}. */
public static class Builder {

Expand All @@ -667,6 +687,7 @@ private Builder(InvocationContext context) {
this.invocationIdSet = true;
this.agent = context.agent;
this.node = context.node;
this.agentModeOverride = context.agentModeOverride;
this.session = context.session;
this.userContent = context.userContent;
this.runConfig = context.runConfig;
Expand Down Expand Up @@ -699,6 +720,7 @@ private Builder(InvocationContext context) {
private boolean invocationIdSet;
private BaseAgent agent;
private @Nullable Node node;
private @Nullable AgentModeOverride agentModeOverride;
private Session session;
@Nullable private Content userContent = null;
private RunConfig runConfig = RunConfig.builder().build();
Expand Down Expand Up @@ -822,6 +844,13 @@ public Builder node(@Nullable Node node) {
return this;
}

/** Sets the mode {@code agent} runs in as a workflow node, replacing any earlier one. */
@CanIgnoreReturnValue
Builder agentMode(BaseAgent agent, AgentMode mode) {
this.agentModeOverride = new AgentModeOverride(agent, mode);
return this;
}

/**
* Sets the session associated with this invocation.
*
Expand Down Expand Up @@ -1024,6 +1053,7 @@ public boolean equals(Object o) {
&& Objects.equals(invocationId, that.invocationId)
&& Objects.equals(agent, that.agent)
&& Objects.equals(node, that.node)
&& Objects.equals(agentModeOverride, that.agentModeOverride)
&& Objects.equals(session, that.session)
&& Objects.equals(userContent, that.userContent)
&& Objects.equals(runConfig, that.runConfig)
Expand All @@ -1050,6 +1080,7 @@ public int hashCode() {
invocationId,
agent,
node,
agentModeOverride,
session,
userContent,
runConfig,
Expand Down
Loading
Loading