diff --git a/google_adk_agents/README.md b/google_adk_agents/README.md index e95dae327..5a7ed3173 100644 --- a/google_adk_agents/README.md +++ b/google_adk_agents/README.md @@ -39,6 +39,7 @@ Each directory contains a complete example with its own README: | Scenario | What it shows | | --- | --- | | [basic](./basic/README.md) | A single ADK agent with `TemporalModel` and one model call — no tools. The minimal end-to-end example. | +| [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. | | [tools](./tools/README.md) | A Temporal activity wrapped as an ADK tool with `activity_tool`, so tool calls run as their own activities. | | [agent_patterns](./agent_patterns/README.md) | A coordinator `LlmAgent` with `sub_agents`, each a `TemporalModel` with a per-agent activity summary. | | [mcp](./mcp/README.md) | A local echo MCP toolset via `TemporalMcpToolSet` / `TemporalMcpToolSetProvider`, running MCP tools as activities. Self-contained, no Node required. | diff --git a/google_adk_agents/chatbot/README.md b/google_adk_agents/chatbot/README.md new file mode 100644 index 000000000..28400b433 --- /dev/null +++ b/google_adk_agents/chatbot/README.md @@ -0,0 +1,43 @@ +# Chatbot — Multi-Turn Conversation via Updates + +A no-frills conversational chatbot: an ADK `Agent` whose +`model=TemporalModel("gemini-2.5-flash")`, driven by an `InMemoryRunner` inside a +workflow that stays alive across turns. Unlike the [basic](../basic/README.md) +single-shot sample, one ADK session persists for the life of the workflow, so +the assistant remembers earlier turns. + +Each conversational turn arrives as a Temporal **Update**: the `message` update +handler feeds the user's text into `runner.run_async` on the persisted session +and returns the assistant's reply as the update result. The handler has a noop +validator that accepts every message. Every model turn still runs as its own +`invoke_model` activity. + +Before running, review the [prerequisites in the suite README](../README.md) +(Temporal dev server, `uv sync --group google-adk`, and +`export GOOGLE_API_KEY=...`). + +## Running + +Start the worker in one terminal: + +```bash +uv run python -m google_adk_agents.chatbot.run_worker +``` + +Then start the interactive client in another terminal: + +```bash +uv run python -m google_adk_agents.chatbot.run_chatbot_workflow +``` + +## What to expect + +The client starts the workflow, then reads messages from stdin. Each line is +sent as an update and the assistant's reply is printed. Enter an empty line or +`/quit` to end the session, which terminates the workflow. + +## In the Temporal UI + +Open the workflow `google-adk-agents-chatbot-workflow-id`. In the history you +will see the workflow stay running to accept updates, with one `invoke_model` +activity per turn. The workflow itself stays deterministic and replay-safe. diff --git a/google_adk_agents/chatbot/__init__.py b/google_adk_agents/chatbot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google_adk_agents/chatbot/run_chatbot_workflow.py b/google_adk_agents/chatbot/run_chatbot_workflow.py new file mode 100644 index 000000000..61d0d6313 --- /dev/null +++ b/google_adk_agents/chatbot/run_chatbot_workflow.py @@ -0,0 +1,34 @@ +import asyncio + +from temporalio.client import Client +from temporalio.contrib.google_adk_agents import GoogleAdkPlugin + +from google_adk_agents.chatbot.workflows.chatbot_workflow import ( + ChatbotAgentWorkflow, +) + + +async def main(): + # @@@SNIPSTART google-adk-agents-chatbot-starter + client = await Client.connect("localhost:7233", plugins=[GoogleAdkPlugin()]) + + handle = await client.start_workflow( + ChatbotAgentWorkflow.run, + id="google-adk-agents-chatbot-workflow-id", + task_queue="google-adk-agents-chatbot", + ) + + print('Chat with the assistant. Enter an empty line or "/quit" to exit.') + while True: + message = input("> ").strip() + if not message or message == "/quit": + break + reply = await handle.execute_update(ChatbotAgentWorkflow.message, message) + print(f"Assistant: {reply}") + + await handle.terminate() + # @@@SNIPEND + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/google_adk_agents/chatbot/run_worker.py b/google_adk_agents/chatbot/run_worker.py new file mode 100644 index 000000000..18a95d39e --- /dev/null +++ b/google_adk_agents/chatbot/run_worker.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import asyncio + +from temporalio.client import Client +from temporalio.contrib.google_adk_agents import GoogleAdkPlugin +from temporalio.worker import Worker + +from google_adk_agents.chatbot.workflows.chatbot_workflow import ( + ChatbotAgentWorkflow, +) + + +async def main(): + # @@@SNIPSTART google-adk-agents-chatbot-worker + plugin = GoogleAdkPlugin() + + client = await Client.connect("localhost:7233", plugins=[plugin]) + + worker = Worker( + client, + task_queue="google-adk-agents-chatbot", + workflows=[ChatbotAgentWorkflow], + plugins=[plugin], + ) + await worker.run() + # @@@SNIPEND + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/google_adk_agents/chatbot/workflows/__init__.py b/google_adk_agents/chatbot/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google_adk_agents/chatbot/workflows/chatbot_workflow.py b/google_adk_agents/chatbot/workflows/chatbot_workflow.py new file mode 100644 index 000000000..5019c2e0b --- /dev/null +++ b/google_adk_agents/chatbot/workflows/chatbot_workflow.py @@ -0,0 +1,71 @@ +import asyncio + +from google.adk import Agent +from google.adk.runners import InMemoryRunner +from google.adk.utils.context_utils import Aclosing +from google.genai import types +from temporalio import workflow +from temporalio.contrib.google_adk_agents import TemporalModel + + +# @@@SNIPSTART google-adk-agents-chatbot-agent-workflow +@workflow.defn +class ChatbotAgentWorkflow: + def __init__(self) -> None: + self._ready = False + self._runner: InMemoryRunner | None = None + self._session_id: str | None = None + + @workflow.run + async def run(self) -> str: + agent = Agent( + name="chatbot_agent", + model=TemporalModel("gemini-2.5-flash"), + instruction="You are a helpful assistant.", + ) + + # The plugin points ADK's session-id generation at workflow.uuid4(), so + # creating a session here is replay-safe. + self._runner = InMemoryRunner(agent=agent, app_name="chatbot_app") + session = await self._runner.session_service.create_session( + app_name="chatbot_app", user_id="user" + ) + self._session_id = session.id + self._ready = True + + # Block forever to stay alive serving update turns; the client + # terminates the workflow when the user quits. + return await asyncio.Future() + + @workflow.update + async def message(self, message: str) -> str: + # An update can arrive before run() has created the runner and session, + # so wait until they are ready before using them. + await workflow.wait_condition(lambda: self._ready) + assert self._runner is not None and self._session_id is not None + + final_text = "" + async with Aclosing( + self._runner.run_async( + user_id="user", + session_id=self._session_id, + new_message=types.Content( + role="user", parts=[types.Part(text=message)] + ), + ) + ) as event_stream: + async for event in event_stream: + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + final_text = part.text + + return final_text + + @message.validator + def validate_message(self, message: str) -> None: + # Verify user messages here + pass + + +# @@@SNIPEND diff --git a/tests/google_adk_agents/_mock_model.py b/tests/google_adk_agents/_mock_model.py index 2b4c8f895..aaa657e12 100644 --- a/tests/google_adk_agents/_mock_model.py +++ b/tests/google_adk_agents/_mock_model.py @@ -30,6 +30,7 @@ def patch_model( responses: list[LlmResponse], *, stream_chunks: bool = False, + captured: list[LlmRequest] | None = None, ) -> None: script = list(responses) orig_new_llm = LLMRegistry.new_llm # staticmethod @@ -38,6 +39,8 @@ class _Mock(BaseLlm): async def generate_content_async( self, llm_request: LlmRequest, stream: bool = False ) -> AsyncGenerator[LlmResponse, None]: + if captured is not None: + captured.append(llm_request) if stream_chunks: # The streaming sample is single-turn, so yield every scripted # chunk on this one call. diff --git a/tests/google_adk_agents/chatbot_test.py b/tests/google_adk_agents/chatbot_test.py new file mode 100644 index 000000000..af01dc826 --- /dev/null +++ b/tests/google_adk_agents/chatbot_test.py @@ -0,0 +1,57 @@ +import uuid + +import pytest +from google.adk.models.llm_request import LlmRequest +from temporalio.client import Client +from temporalio.contrib.google_adk_agents import GoogleAdkPlugin +from temporalio.worker import Worker + +from google_adk_agents.chatbot.workflows.chatbot_workflow import ( + ChatbotAgentWorkflow, +) +from tests.google_adk_agents._mock_model import patch_model, text + + +async def test_chatbot(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + captured: list[LlmRequest] = [] + patch_model( + monkeypatch, [text("first reply"), text("second reply")], captured=captured + ) + + task_queue = f"google-adk-agents-chatbot-{uuid.uuid4()}" + plugin = GoogleAdkPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatbotAgentWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + ChatbotAgentWorkflow.run, + id=f"google-adk-agents-chatbot-{uuid.uuid4()}", + task_queue=task_queue, + ) + + first = await handle.execute_update(ChatbotAgentWorkflow.message, "Hello") + second = await handle.execute_update(ChatbotAgentWorkflow.message, "Again") + + await handle.terminate() + + assert first == "first reply" + assert second == "second reply" + + # The second turn reuses the same session, so its request carries the first + # turn's history. + turn_two_history = "\n".join( + part.text + for content in captured[1].contents + for part in (content.parts or []) + if part.text + ) + assert "Hello" in turn_two_history + assert "first reply" in turn_two_history