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
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ contains more complete end-to-end samples for customers to use or modify.
filesystem server as a toolset.
- [`spring_chat`](spring_chat/README.md): a Spring Boot chat page backed by an
agent that calls its model through Spring AI.
- [`spring_chat_advanced`](spring_chat_advanced/README.md): the Spring Boot
chat with streamed replies, mixing ADK tools with Spring AI `@Tool` methods.
1 change: 1 addition & 0 deletions examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@
<module>helloworld</module>
<module>mcpfilesystem</module>
<module>spring_chat</module>
<module>spring_chat_advanced</module>
</modules>
</project>
57 changes: 57 additions & 0 deletions examples/spring_chat_advanced/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Spring Chat Advanced Sample

A Spring Boot order-support chat backed by an ADK agent that calls its model
through Spring AI, using the ADK Spring AI integration (`google-adk-spring-ai`).
For the minimal version, see [`spring_chat`](../spring_chat/README.md).

## How it fits together

- `OrderService` is a plain Spring bean. `AgentConfig` turns two of its
methods into ADK `FunctionTool`s, so tools can use anything Spring injects.
`FunctionTool` reads the `@Schema` annotations from the bean's class, so if
you add aspects such as `@Transactional`, which make Spring proxy the bean,
pass the `Method` from `OrderService.class` instead of a method name.
- `ShippingTools` holds Spring AI `@Tool` methods. `AgentConfig` wraps them in
a `SpringAiToolset`, so the agent uses them next to the ADK tools.
`getTracking` records the tracked order in the ADK session state, which the
agent's instruction reads. `talkToHuman` uses `returnDirect`, so its result
goes to the user without another model call. Tools the model calls in the
same turn still run, but get no model reply.
- `ChatController` runs the agent in `StreamingMode.SSE` and forwards its
events to the browser as server-sent events: reply chunks as they arrive,
the complete reply, each tool call and result, and why the model stopped if
it ended early.
- `static/index.html` reads the stream with `fetch` and shows the reply as it
grows.
- When the browser has disconnected, the controller notices on its next send
and cancels the agent run, which cancels the model call.

Each page load gets its own ADK session, so the agent remembers the conversation
until you reload. Sessions are kept in memory until the app stops.

## Prerequisites

- Java 17+
- A Google Cloud project with Vertex AI enabled, and Application Default
Credentials (`gcloud auth application-default login`).

The app uses Vertex AI by default. Set `GOOGLE_CLOUD_PROJECT`, and optionally
`GOOGLE_CLOUD_LOCATION` (default `global`) and `GEMINI_MODEL`. To use the Gemini
Developer API instead, set `GOOGLE_GENAI_USE_VERTEXAI=false` and
`GOOGLE_API_KEY`.

## Run

From the repository root:

```bash
export GOOGLE_CLOUD_PROJECT=your-project-id
./mvnw install -DskipTests -pl examples/spring_chat_advanced -am
./mvnw spring-boot:run -pl examples/spring_chat_advanced
```

Then open http://localhost:8080 and ask, for example, "When do orders 1001 and
1002 arrive?". The chat endpoint has no authentication, so the app listens on
`localhost` only, which keeps other machines out. Set `SERVER_ADDRESS=0.0.0.0`
to accept connections from other hosts. Don't expose it beyond a trusted
environment without adding authentication.
96 changes: 96 additions & 0 deletions examples/spring_chat_advanced/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.google.adk</groupId>
<artifactId>google-adk-examples</artifactId>
<version>1.13.0</version><!-- {x-version-update:google-adk:current} -->
<relativePath>..</relativePath>
</parent>

<groupId>com.google.adk.samples</groupId>
<artifactId>google-adk-sample-spring-chat-advanced</artifactId>
<name>Google ADK - Sample - Spring Chat Advanced</name>
<description>
A Spring Boot chat app that streams replies from an ADK agent mixing ADK tools with Spring AI
@Tool methods, with the model called through Spring AI.
</description>
<packaging>jar</packaging>

<properties>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk-spring-ai</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Any Spring AI chat model starter works; swap this one to change providers. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>
<!-- Spring AI's tool API, which ShippingTools and AgentConfig use directly. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
<plugin>
<!-- Like the contextcaching demo, this example is not released to Maven. -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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.example.springchat.advanced;

import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.LlmAgent;
import com.google.adk.models.springai.SpringAI;
import com.google.adk.models.springai.SpringAiToolset;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.runner.Runner;
import com.google.adk.tools.FunctionTool;
import com.google.common.collect.ImmutableList;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/** Spring configuration for the ADK agent, its tools and the runner. */
@Configuration(proxyBeanMethods = false)
class AgentConfig {

private static final String APP_NAME = "spring-chat-advanced";

/**
* Returns the order support agent. It mixes ADK tools built from {@link OrderService} methods
* with the Spring AI {@code @Tool} methods of {@link ShippingTools}, exposed as an ADK toolset.
*/
@Bean
BaseAgent orderAgent(SpringAI model, OrderService orderService, ShippingTools shippingTools) {
ImmutableList<Object> tools =
ImmutableList.of(
FunctionTool.create(orderService, "getOrderStatus"),
FunctionTool.create(orderService, "getDeliveryEstimate"),
new SpringAiToolset(
MethodToolCallbackProvider.builder().toolObjects(shippingTools).build()));
return LlmAgent.builder()
.name("order_support_agent")
.description("Answers questions about the user's orders.")
.model(model)
.instruction(
"You are a concise customer support assistant. Use the tools to look up orders; never"
+ " guess order details. The order the user last tracked, if any: {"
+ ShippingTools.LAST_TRACKED_ORDER
+ "?}")
.tools(tools)
.build();
}

@Bean
Runner runner(BaseAgent orderAgent) {
return new InMemoryRunner(orderAgent, APP_NAME);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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.example.springchat.advanced;

import com.google.adk.agents.RunConfig;
import com.google.adk.agents.RunConfig.StreamingMode;
import com.google.adk.events.Event;
import com.google.adk.runner.Runner;
import com.google.genai.types.Content;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

/** REST endpoint that streams the ADK agent's events to the browser as server-sent events. */
@RestController
class ChatController {

private static final Logger logger = LoggerFactory.getLogger(ChatController.class);

// The sample has no login, so every browser tab shares one user and keeps its own session.
private static final String USER_ID = "spring-chat-user";

private static final RunConfig RUN_CONFIG =
RunConfig.builder().autoCreateSession(true).streamingMode(StreamingMode.SSE).build();

private static final Duration STREAM_TIMEOUT = Duration.ofMinutes(5);

private final Runner runner;

ChatController(Runner runner) {
this.runner = runner;
}

/** A message from the browser; {@code sessionId} is generated client-side once per page load. */
private record ChatRequest(@Nullable String sessionId, @Nullable String message) {}

/**
* One streamed update. {@code type} is {@code partial} for a chunk of a reply, {@code text} for
* the complete reply that replaces its chunks, {@code tool_call}, {@code tool_result} or {@code
* error}, whose {@code text} says why the model stopped, if known.
*/
private record StreamEvent(String type, @Nullable String text, @Nullable String tool) {}

@PostMapping(path = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter chat(@RequestBody ChatRequest request) {
if (isBlank(request.sessionId()) || isBlank(request.message())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
// The session service trims ids it creates, so look them up trimmed too.
String sessionId = request.sessionId().trim();
Content userMessage = Content.fromParts(Part.fromText(request.message()));

SseEmitter emitter = new SseEmitter(STREAM_TIMEOUT.toMillis());
Disposable subscription =
runner
.runAsync(USER_ID, sessionId, userMessage, RUN_CONFIG)
.subscribeOn(Schedulers.io())
.subscribe(
event -> send(emitter, event),
error -> sendError(emitter, error),
emitter::complete);
// Runs however the request ends, including timeouts and a failed send to a browser that went
// away, and stops the agent run and with it the model call.
emitter.onCompletion(subscription::dispose);
emitter.onTimeout(
() -> {
trySend(emitter, new StreamEvent("error", null, null));
emitter.complete();
});
return emitter;
}

private static void send(SseEmitter emitter, Event event) {
boolean partial = event.partial().orElse(false);
String text =
event.content().flatMap(Content::parts).stream()
.flatMap(parts -> parts.stream())
.filter(part -> !part.thought().orElse(false))
.flatMap(part -> part.text().stream())
.collect(Collectors.joining());
if (!text.isEmpty()) {
trySend(emitter, new StreamEvent(partial ? "partial" : "text", text, null));
}
// Partial events preview function calls; the complete event that follows carries them too.
if (partial) {
return;
}
// Says why the model stopped, for example when the reply was cut short or blocked. An event
// that still asks for tools doesn't end the turn, so the agent carries on after it.
if (event.finalResponse()) {
event
.errorMessage()
.ifPresent(error -> trySend(emitter, new StreamEvent("error", error, null)));
}
for (FunctionCall call : event.functionCalls()) {
trySend(emitter, new StreamEvent("tool_call", null, call.name().orElse("")));
}
for (FunctionResponse response : event.functionResponses()) {
trySend(emitter, new StreamEvent("tool_result", null, response.name().orElse("")));
}
// A returnDirect tool's text result is the reply; parallel tools' map results are not shown.
if (event.actions().skipSummarization().orElse(false)) {
for (FunctionResponse response : event.functionResponses()) {
if (response.response().orElse(Map.of()).get("result") instanceof String result) {
trySend(emitter, new StreamEvent("text", result, null));
}
}
}
}

private static void sendError(SseEmitter emitter, Throwable error) {
logger.warn("Agent run failed: {}", error.getClass().getName());
trySend(emitter, new StreamEvent("error", null, null));
emitter.complete();
}

/**
* Sends {@code event}, dropping it if the browser has gone away. The emitter then completes and
* cancels the run, so the failure must not reach the cancelled subscriber.
*/
private static void trySend(SseEmitter emitter, StreamEvent event) {
try {
emitter.send(event);
} catch (IOException | IllegalStateException e) {
// Nothing left to tell the browser.
}
}

private static boolean isBlank(@Nullable String s) {
return s == null || s.trim().isEmpty();
}
}
Loading
Loading