diff --git a/examples/README.md b/examples/README.md index d7304d1eb..0981e04b1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/pom.xml b/examples/pom.xml index 8df3003ef..7b50839d5 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -38,5 +38,6 @@ helloworld mcpfilesystem spring_chat + spring_chat_advanced diff --git a/examples/spring_chat_advanced/README.md b/examples/spring_chat_advanced/README.md new file mode 100644 index 000000000..021f3caf5 --- /dev/null +++ b/examples/spring_chat_advanced/README.md @@ -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. diff --git a/examples/spring_chat_advanced/pom.xml b/examples/spring_chat_advanced/pom.xml new file mode 100644 index 000000000..2c42eaa71 --- /dev/null +++ b/examples/spring_chat_advanced/pom.xml @@ -0,0 +1,96 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-examples + 1.13.0 + .. + + + com.google.adk.samples + google-adk-sample-spring-chat-advanced + Google ADK - Sample - Spring Chat Advanced + + 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. + + jar + + + 2.0.1 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + google-adk-spring-ai + ${project.version} + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.ai + spring-ai-starter-model-google-genai + + + + org.springframework.ai + spring-ai-model + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + diff --git a/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/AgentConfig.java b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/AgentConfig.java new file mode 100644 index 000000000..8ccd38f55 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/AgentConfig.java @@ -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 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); + } +} diff --git a/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ChatController.java b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ChatController.java new file mode 100644 index 000000000..f2d2698b9 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ChatController.java @@ -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(); + } +} diff --git a/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/OrderService.java b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/OrderService.java new file mode 100644 index 000000000..62ba71d38 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/OrderService.java @@ -0,0 +1,67 @@ +/* + * 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.tools.Annotations.Schema; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** + * A Spring service whose methods {@link AgentConfig} exposes to the agent as tools. A real app + * would back it with a database or another service. + */ +@Service +public class OrderService { + + private record Order(String status, String item, String deliveryEstimate) {} + + private static final ImmutableMap ORDERS = + ImmutableMap.of( + "1001", new Order("shipped", "mechanical keyboard", "2 days"), + "1002", new Order("processing", "USB-C hub", "5 days"), + "1003", new Order("delivered", "monitor stand", "already delivered")); + + /** + * Returns the status and item of the order, or an {@code error} entry if there is no such order. + */ + @Schema(description = "Looks up the status and item of an order.") + public Map getOrderStatus( + @Schema(name = "orderId", description = "The order number, for example 1001.") + String orderId) { + Order order = ORDERS.get(orderId); + if (order == null) { + return ImmutableMap.of("error", "No order with this number."); + } + return ImmutableMap.of("orderId", orderId, "status", order.status(), "item", order.item()); + } + + /** + * Returns the delivery estimate of the order, or an {@code error} entry if there is no such + * order. + */ + @Schema(description = "Returns the delivery estimate of an order.") + public Map getDeliveryEstimate( + @Schema(name = "orderId", description = "The order number, for example 1001.") + String orderId) { + Order order = ORDERS.get(orderId); + if (order == null) { + return ImmutableMap.of("error", "No order with this number."); + } + return ImmutableMap.of("orderId", orderId, "deliveryEstimate", order.deliveryEstimate()); + } +} diff --git a/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ShippingTools.java b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ShippingTools.java new file mode 100644 index 000000000..4000135b6 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/ShippingTools.java @@ -0,0 +1,63 @@ +/* + * 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.models.springai.ToolConverter; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +/** Spring AI {@code @Tool} methods that {@link AgentConfig} gives the agent as ADK tools. */ +@Component +class ShippingTools { + + /** Session state key that {@code getTracking} sets and the agent's instruction reads. */ + static final String LAST_TRACKED_ORDER = "lastTrackedOrder"; + + private static final ImmutableMap TRACKING_NUMBERS = + ImmutableMap.of("1001", "DHL JD014600001001", "1003", "UPS 1Z999AA10000001003"); + + /** + * Returns the tracking number of a shipped order and records the order in the ADK session. The + * ADK tool context reaches Spring AI tools under {@link ToolConverter#ADK_TOOL_CONTEXT_KEY}. + */ + @Tool(description = "Returns the carrier and tracking number of a shipped order.") + Map getTracking( + @ToolParam(description = "The order number, for example 1001.") String orderId, + ToolContext toolContext) { + String trackingNumber = TRACKING_NUMBERS.get(orderId); + if (trackingNumber == null) { + return ImmutableMap.of("error", "This order has no tracking number yet."); + } + if (toolContext.getContext().get(ToolConverter.ADK_TOOL_CONTEXT_KEY) + instanceof com.google.adk.tools.ToolContext adkToolContext) { + adkToolContext.state().put(LAST_TRACKED_ORDER, orderId); + } + return ImmutableMap.of("orderId", orderId, "tracking", trackingNumber); + } + + /** With {@code returnDirect}, ADK shows this result to the user without another model call. */ + @Tool( + description = "Hands the conversation to a human support agent when the user asks for one.", + returnDirect = true) + String talkToHuman() { + return "A human support agent will join this chat within 5 minutes."; + } +} diff --git a/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/SpringChatAdvancedApplication.java b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/SpringChatAdvancedApplication.java new file mode 100644 index 000000000..d469dfc8f --- /dev/null +++ b/examples/spring_chat_advanced/src/main/java/com/example/springchat/advanced/SpringChatAdvancedApplication.java @@ -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.example.springchat.advanced; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Spring Boot entry point for the advanced chat sample. */ +@SpringBootApplication +public class SpringChatAdvancedApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringChatAdvancedApplication.class, args); + } +} diff --git a/examples/spring_chat_advanced/src/main/resources/application.properties b/examples/spring_chat_advanced/src/main/resources/application.properties new file mode 100644 index 000000000..5136a5856 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/resources/application.properties @@ -0,0 +1,11 @@ +# Spring AI Google GenAI chat model. Vertex AI with Application Default Credentials is the default: +# set GOOGLE_CLOUD_PROJECT (and optionally GOOGLE_CLOUD_LOCATION). To use the Gemini Developer API +# instead, set GOOGLE_GENAI_USE_VERTEXAI=false and GOOGLE_API_KEY. +spring.ai.google.genai.vertex-ai=${GOOGLE_GENAI_USE_VERTEXAI:true} +spring.ai.google.genai.project-id=${GOOGLE_CLOUD_PROJECT:} +spring.ai.google.genai.location=${GOOGLE_CLOUD_LOCATION:global} +spring.ai.google.genai.api-key=${GOOGLE_API_KEY:} +spring.ai.google.genai.chat.model=${GEMINI_MODEL:gemini-3.8-flash} + +# The chat endpoint has no authentication, so listen on loopback only unless told otherwise. +server.address=${SERVER_ADDRESS:localhost} diff --git a/examples/spring_chat_advanced/src/main/resources/static/index.html b/examples/spring_chat_advanced/src/main/resources/static/index.html new file mode 100644 index 000000000..8df433c70 --- /dev/null +++ b/examples/spring_chat_advanced/src/main/resources/static/index.html @@ -0,0 +1,139 @@ + + + + + + ADK Spring Chat: Order Support + + + +

Order Support

+

Ask about orders 1001, 1002 or 1003, track a shipped order, or ask for a human.

+
+
+ + +
+ + + +