Skip to content

Commit 680a04a

Browse files
committed
Add Google ADK chatbot sample
A multi-turn conversational chatbot under google_adk_agents: one persisted ADK session across turns, each turn driven by a workflow Update handler that returns the assistant's reply, plus a no-op update validator.
1 parent 9c0f306 commit 680a04a

9 files changed

Lines changed: 239 additions & 0 deletions

File tree

google_adk_agents/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Each directory contains a complete example with its own README:
3939
| Scenario | What it shows |
4040
| --- | --- |
4141
| [basic](./basic/README.md) | A single ADK agent with `TemporalModel` and one model call — no tools. The minimal end-to-end example. |
42+
| [chatbot](./chatbot/README.md) | A multi-turn conversation over one persisted ADK session, with each turn driven by a workflow Update handler that returns the assistant's reply. |
4243
| [tools](./tools/README.md) | A Temporal activity wrapped as an ADK tool with `activity_tool`, so tool calls run as their own activities. |
4344
| [agent_patterns](./agent_patterns/README.md) | A coordinator `LlmAgent` with `sub_agents`, each a `TemporalModel` with a per-agent activity summary. |
4445
| [mcp](./mcp/README.md) | A local echo MCP toolset via `TemporalMcpToolSet` / `TemporalMcpToolSetProvider`, running MCP tools as activities. Self-contained, no Node required. |
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Chatbot — Multi-Turn Conversation via Updates
2+
3+
A no-frills conversational chatbot: an ADK `Agent` whose
4+
`model=TemporalModel("gemini-2.5-flash")`, driven by an `InMemoryRunner` inside a
5+
workflow that stays alive across turns. Unlike the [basic](../basic/README.md)
6+
single-shot sample, one ADK session persists for the life of the workflow, so
7+
the assistant remembers earlier turns.
8+
9+
Each conversational turn arrives as a Temporal **Update**: the `message` update
10+
handler feeds the user's text into `runner.run_async` on the persisted session
11+
and returns the assistant's reply as the update result. The handler has a noop
12+
validator that accepts every message. Every model turn still runs as its own
13+
`invoke_model` activity.
14+
15+
Before running, review the [prerequisites in the suite README](../README.md)
16+
(Temporal dev server, `uv sync --group google-adk`, and
17+
`export GOOGLE_API_KEY=...`).
18+
19+
## Running
20+
21+
Start the worker in one terminal:
22+
23+
```bash
24+
uv run python -m google_adk_agents.chatbot.run_worker
25+
```
26+
27+
Then start the interactive client in another terminal:
28+
29+
```bash
30+
uv run python -m google_adk_agents.chatbot.run_chatbot_workflow
31+
```
32+
33+
## What to expect
34+
35+
The client starts the workflow, then reads messages from stdin. Each line is
36+
sent as an update and the assistant's reply is printed. Enter an empty line or
37+
`/quit` to end the session, which terminates the workflow.
38+
39+
## In the Temporal UI
40+
41+
Open the workflow `google-adk-agents-chatbot-workflow-id`. In the history you
42+
will see the workflow stay running to accept updates, with one `invoke_model`
43+
activity per turn. The workflow itself stays deterministic and replay-safe.

google_adk_agents/chatbot/__init__.py

Whitespace-only changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import asyncio
2+
3+
from temporalio.client import Client
4+
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
5+
6+
from google_adk_agents.chatbot.workflows.chatbot_workflow import (
7+
ChatbotAgentWorkflow,
8+
)
9+
10+
11+
async def main():
12+
# @@@SNIPSTART google-adk-agents-chatbot-starter
13+
client = await Client.connect("localhost:7233", plugins=[GoogleAdkPlugin()])
14+
15+
handle = await client.start_workflow(
16+
ChatbotAgentWorkflow.run,
17+
id="google-adk-agents-chatbot-workflow-id",
18+
task_queue="google-adk-agents-chatbot",
19+
)
20+
21+
print('Chat with the assistant. Enter an empty line or "/quit" to exit.')
22+
while True:
23+
message = input("> ").strip()
24+
if not message or message == "/quit":
25+
break
26+
reply = await handle.execute_update(ChatbotAgentWorkflow.message, message)
27+
print(f"Assistant: {reply}")
28+
29+
await handle.terminate()
30+
# @@@SNIPEND
31+
32+
33+
if __name__ == "__main__":
34+
asyncio.run(main())
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
5+
from temporalio.client import Client
6+
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
7+
from temporalio.worker import Worker
8+
9+
from google_adk_agents.chatbot.workflows.chatbot_workflow import (
10+
ChatbotAgentWorkflow,
11+
)
12+
13+
14+
async def main():
15+
# @@@SNIPSTART google-adk-agents-chatbot-worker
16+
plugin = GoogleAdkPlugin()
17+
18+
client = await Client.connect("localhost:7233", plugins=[plugin])
19+
20+
worker = Worker(
21+
client,
22+
task_queue="google-adk-agents-chatbot",
23+
workflows=[ChatbotAgentWorkflow],
24+
plugins=[plugin],
25+
)
26+
await worker.run()
27+
# @@@SNIPEND
28+
29+
30+
if __name__ == "__main__":
31+
asyncio.run(main())

google_adk_agents/chatbot/workflows/__init__.py

Whitespace-only changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import asyncio
2+
3+
from google.adk import Agent
4+
from google.adk.runners import InMemoryRunner
5+
from google.adk.utils.context_utils import Aclosing
6+
from google.genai import types
7+
from temporalio import workflow
8+
from temporalio.contrib.google_adk_agents import TemporalModel
9+
10+
11+
# @@@SNIPSTART google-adk-agents-chatbot-agent-workflow
12+
@workflow.defn
13+
class ChatbotAgentWorkflow:
14+
def __init__(self) -> None:
15+
self._ready = False
16+
self._runner: InMemoryRunner | None = None
17+
self._session_id: str | None = None
18+
19+
@workflow.run
20+
async def run(self) -> str:
21+
agent = Agent(
22+
name="chatbot_agent",
23+
model=TemporalModel("gemini-2.5-flash"),
24+
instruction="You are a helpful assistant.",
25+
)
26+
27+
# The plugin points ADK's session-id generation at workflow.uuid4(), so
28+
# creating a session here is replay-safe.
29+
self._runner = InMemoryRunner(agent=agent, app_name="chatbot_app")
30+
session = await self._runner.session_service.create_session(
31+
app_name="chatbot_app", user_id="user"
32+
)
33+
self._session_id = session.id
34+
self._ready = True
35+
36+
# Block forever to stay alive serving update turns; the client
37+
# terminates the workflow when the user quits.
38+
return await asyncio.Future()
39+
40+
@workflow.update
41+
async def message(self, message: str) -> str:
42+
# An update can arrive before run() has created the runner and session,
43+
# so wait until they are ready before using them.
44+
await workflow.wait_condition(lambda: self._ready)
45+
assert self._runner is not None and self._session_id is not None
46+
47+
final_text = ""
48+
async with Aclosing(
49+
self._runner.run_async(
50+
user_id="user",
51+
session_id=self._session_id,
52+
new_message=types.Content(
53+
role="user", parts=[types.Part(text=message)]
54+
),
55+
)
56+
) as agen:
57+
async for event in agen:
58+
if event.content and event.content.parts:
59+
for part in event.content.parts:
60+
if part.text:
61+
final_text = part.text
62+
63+
return final_text
64+
65+
@message.validator
66+
def validate_message(self, message: str) -> None:
67+
pass
68+
69+
70+
# @@@SNIPEND

tests/google_adk_agents/_mock_model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def patch_model(
3030
responses: list[LlmResponse],
3131
*,
3232
stream_chunks: bool = False,
33+
captured: list[LlmRequest] | None = None,
3334
) -> None:
3435
script = list(responses)
3536
orig_new_llm = LLMRegistry.new_llm # staticmethod
@@ -38,6 +39,8 @@ class _Mock(BaseLlm):
3839
async def generate_content_async(
3940
self, llm_request: LlmRequest, stream: bool = False
4041
) -> AsyncGenerator[LlmResponse, None]:
42+
if captured is not None:
43+
captured.append(llm_request)
4144
if stream_chunks:
4245
# The streaming sample is single-turn, so yield every scripted
4346
# chunk on this one call.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import uuid
2+
3+
import pytest
4+
from google.adk.models.llm_request import LlmRequest
5+
from temporalio.client import Client
6+
from temporalio.contrib.google_adk_agents import GoogleAdkPlugin
7+
from temporalio.worker import Worker
8+
9+
from google_adk_agents.chatbot.workflows.chatbot_workflow import (
10+
ChatbotAgentWorkflow,
11+
)
12+
from tests.google_adk_agents._mock_model import patch_model, text
13+
14+
15+
async def test_chatbot(client: Client, monkeypatch: pytest.MonkeyPatch) -> None:
16+
captured: list[LlmRequest] = []
17+
patch_model(
18+
monkeypatch, [text("first reply"), text("second reply")], captured=captured
19+
)
20+
21+
task_queue = f"google-adk-agents-chatbot-{uuid.uuid4()}"
22+
plugin = GoogleAdkPlugin()
23+
24+
config = client.config()
25+
config["plugins"] = [*config["plugins"], plugin]
26+
client = Client(**config)
27+
28+
async with Worker(
29+
client,
30+
task_queue=task_queue,
31+
workflows=[ChatbotAgentWorkflow],
32+
max_cached_workflows=0,
33+
):
34+
handle = await client.start_workflow(
35+
ChatbotAgentWorkflow.run,
36+
id=f"google-adk-agents-chatbot-{uuid.uuid4()}",
37+
task_queue=task_queue,
38+
)
39+
40+
first = await handle.execute_update(ChatbotAgentWorkflow.message, "Hello")
41+
second = await handle.execute_update(ChatbotAgentWorkflow.message, "Again")
42+
43+
await handle.terminate()
44+
45+
assert first == "first reply"
46+
assert second == "second reply"
47+
48+
# The second turn reuses the same session, so its request carries the first
49+
# turn's history.
50+
turn_two_history = "\n".join(
51+
part.text
52+
for content in captured[1].contents
53+
for part in (content.parts or [])
54+
if part.text
55+
)
56+
assert "Hello" in turn_two_history
57+
assert "first reply" in turn_two_history

0 commit comments

Comments
 (0)