From 644aed84f4e4536605cc34d1e5875050782ab210 Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 13 Aug 2026 17:08:40 +0200 Subject: [PATCH 1/4] Add AI agent example wiring LangGraph + Vertex AI Gemini into a Symphony bot Mirrors the Java BDK's bdk-ai-agent-example: any message addressed to the bot is forwarded to a LangGraph ReAct agent backed by Gemini, with BDK-backed tools for user lookup, room member listing, and sending messages. --- CLAUDE.md | 68 ++++++++++++++++++ examples/ai_agent/README.md | 69 ++++++++++++++++++ examples/ai_agent/__init__.py | 0 examples/ai_agent/agent.py | 32 +++++++++ examples/ai_agent/ask_ai_activity.py | 30 ++++++++ examples/ai_agent/main.py | 26 +++++++ examples/ai_agent/memory_ids.py | 11 +++ examples/ai_agent/tools.py | 41 +++++++++++ .../.openspec.yaml | 2 + .../2026-08-13-add-ai-agent-example/design.md | 70 +++++++++++++++++++ .../proposal.md | 37 ++++++++++ .../specs/ai-agent-example/spec.md | 47 +++++++++++++ .../2026-08-13-add-ai-agent-example/tasks.md | 62 ++++++++++++++++ openspec/config.yaml | 20 ++++++ openspec/specs/ai-agent-example/spec.md | 55 +++++++++++++++ 15 files changed, 570 insertions(+) create mode 100644 CLAUDE.md create mode 100644 examples/ai_agent/README.md create mode 100644 examples/ai_agent/__init__.py create mode 100644 examples/ai_agent/agent.py create mode 100644 examples/ai_agent/ask_ai_activity.py create mode 100644 examples/ai_agent/main.py create mode 100644 examples/ai_agent/memory_ids.py create mode 100644 examples/ai_agent/tools.py create mode 100644 openspec/changes/archive/2026-08-13-add-ai-agent-example/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-13-add-ai-agent-example/design.md create mode 100644 openspec/changes/archive/2026-08-13-add-ai-agent-example/proposal.md create mode 100644 openspec/changes/archive/2026-08-13-add-ai-agent-example/specs/ai-agent-example/spec.md create mode 100644 openspec/changes/archive/2026-08-13-add-ai-agent-example/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/ai-agent-example/spec.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..93db93da --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +Symphony BDK for Python — a bot development kit on top of the Symphony REST APIs (FINOS project). Package name on disk is `symphony`, import root `symphony.bdk`. Python >=3.9 (excluding 3.9.1), managed with Poetry. + +## Commands + +```bash +poetry install # install deps (dev group included) +poetry run pytest # run full test suite (unit tests only; e2e excluded) +poetry run pytest tests/core/service/message/message_service_test.py::TestClass::test_method # single test +poetry run pytest -m e2e --no-cov # run e2e tests (separate marker, needs real Symphony env) +poetry run ruff check symphony examples # lint +poetry run ruff format symphony examples # format +poetry run pylint # legacy lint (still configured via .pylintrc) +cd docsrc && make html # build Sphinx docs locally +poetry build # build package +``` + +Tests use `pytest-asyncio` — async test functions are marked with `@pytest.mark.asyncio`. Coverage config excludes `symphony/bdk/gen/*` and enforces `fail_under = 90.0` (see `pyproject.toml`). Ruff excludes `symphony/bdk/gen` (generated code, not linted/formatted). Pre-commit hooks run `ruff format` + `ruff check --fix` (`.pre-commit-config.yaml`). + +## Architecture + +### Generated vs. hand-written code + +`symphony/bdk/gen/` (~440 files) is **generated** from the Symphony OpenAPI spec via `openapi-generator` (fork at `SymphonyPlatformSolutions/openapi-generator`) — never edit it by hand. It contains low-level API/model classes split by API family: `agent_api`/`agent_model`, `auth_api`/`auth_model`, `pod_api`/`pod_model`, `login_api`/`login_model`, `group_api`/`group_model`. To regenerate: build the generator JAR (see `api_client_generation/`), then run `api_client_generation/generate.sh`. This is a rare, deliberate operation, not something to do incidentally. + +Everything under `symphony/bdk/core/` and `symphony/bdk/ext/` is hand-written, higher-level BDK code that wraps the generated clients. + +### Entry point and service wiring + +`symphony.bdk.core.symphony_bdk.SymphonyBdk` is the single entry point (usable as an async context manager). Constructing it with a `BdkConfig` wires up: +- `ApiClientFactory` (`core/client/api_client_factory.py`) — builds the low-level pod/agent API clients from config. +- `AuthenticatorFactory` (`core/auth/`) — builds bot / OBO / extension-app authenticators; produces `AuthSession`/`OboAuthSession`. +- `ServiceFactory` (`core/service_factory.py`) — lazily builds all domain services (message, stream, user, connection, application, signal, session, presence, health, datafeed/datahose loops) from the api client factory + bot session. + +Bot services are only initialized if `config.bot.is_authentication_configured()`; otherwise only OBO (on-behalf-of) flows via app authentication are available. Public accessor methods on `SymphonyBdk` (`.messages()`, `.streams()`, `.datafeed()`, etc.) are guarded by the `@bot_service` decorator, which raises `BotNotConfiguredError` if the bot isn't configured; app-scoped accessors (`.app_authenticator()`, `.obo()`, `.obo_services()`) use `@app_service` and raise `BdkConfigError` instead. + +### Config + +`core/config/loader.py` (`BdkConfigLoader`) loads a `BdkConfig` (`core/config/model/`) from a file path, raw YAML/JSON string, or the `$HOME/.symphony/` convention directory — the latter is the standard place to keep credentials/config out of the repo. + +### Activities and datafeed + +Bots react to events via the datafeed loop (`core/service/datafeed/` — v1 and v2 implementations, plus `datahose_loop.py` for the datahose alternative) which pushes real-time events to subscribers implementing `RealTimeEventListener`. `ActivityRegistry` (`core/activity/registry.py`) subscribes to the datafeed loop and dispatches to registered activities (slash `command.py`, `form.py` for elements/forms, `user_joined_room.py`). This registry is only created when bot services are initialized. + +### Auth model + +Three authentication flows live under `core/auth/`: bot (service account, RSA or shared-secret via `bot_authenticator.py`), OBO (`obo_authenticator.py`, acting as a user through an extension app), and extension app auth (`ext_app_authenticator.py`, JWT-based via `jwt_helper.py`). `TokensRepository` handles cached session/key-manager tokens. + +### Extensions + +`core/extension.py` (`ExtensionService`) plus `symphony/bdk/ext/` provide the extension mechanism for optional/pluggable BDK modules (e.g. `ext/group.py`). + +### Retry + +`core/retry/` wraps `tenacity` to provide the BDK's async retry strategy for API calls (network failures, rate limiting). + +## Tests + +Test tree under `tests/` mirrors `symphony/bdk/` package structure 1:1 (e.g. `symphony/bdk/core/service/message/` → `tests/core/service/message/`). Fixtures/sample payloads live in `tests/resources/`, grouped by domain (`message_response/`, `session/`, `stream/`, etc.). + +## Examples + +`examples/` contains runnable snippets per feature area (`authentication/`, `datafeed/`, `activities/`, `services/`, `extension/`, `multiple_instances/`) — useful as reference for intended usage patterns when implementing or modifying core features. diff --git a/examples/ai_agent/README.md b/examples/ai_agent/README.md new file mode 100644 index 00000000..b27a87c8 --- /dev/null +++ b/examples/ai_agent/README.md @@ -0,0 +1,69 @@ +# AI Agent example + +Wires an LLM agent (LangGraph + Vertex AI Gemini) into a Symphony bot: any message addressed to +the bot (`@BotMention `) is forwarded to the agent, which can call BDK-backed tools and +replies in the same stream. Mirrors the Java BDK's `bdk-ai-agent-example`. + +## Architecture + +``` +Symphony message --> AskAiActivity (CommandActivity) --> LangGraph ReAct agent (Gemini) + | + +------------+------------+ + | | | + lookup_user list_current send_message + _room_members _to_stream +``` + +Conversation memory is kept in-memory (LangGraph `MemorySaver`), keyed per `(stream_id, user_id)` +via a `thread_id` built as `f"{stream_id}::{user_id}"` (`memory_ids.py`). Memory does not survive +process restarts. + +## Prerequisites + +- A working `~/.symphony/config.yaml` bot configuration (see the repo's `examples/authentication` + for reference). +- Extra dependencies, not part of the BDK's own dependencies: + + ```bash + pip install langgraph langchain-google-vertexai + ``` + +- Google Cloud auth for Vertex AI: + + ```bash + gcloud auth application-default login + ``` + +## Environment variables + +| Variable | Required | Default | Description | +|---------------------|----------|-----------------------|---------------------------------------| +| `GCP_PROJECT_ID` | yes | - | GCP project used for Vertex AI calls | +| `GCP_LOCATION` | no | `us-central1` | Vertex AI region | +| `GEMINI_MODEL_NAME` | no | `gemini-1.5-flash` | Gemini model used by the agent | + +## Running + +From the repository root: + +```bash +GCP_PROJECT_ID=my-project python -m examples.ai_agent.main +``` + +## Usage + +- `@BotMention what can you do?` — general question, answered directly by the LLM. +- `@BotMention what's the email of jdoe?` — triggers the user-lookup tool. +- `@BotMention who's in this room?` — triggers the room-members tool, scoped to the stream the + question came from. +- `@BotMention send "hello" to stream ` — triggers the send-message tool. +- Asking a follow-up question in the same stream reuses conversation memory; the same question + from a different user, or in a different stream, starts a new conversation. + +## Limitations + +- Conversation memory is in-memory only (lost on restart), same as the Java example. +- Tested against LangGraph `>=0.2` and `langchain-google-vertexai`'s current API surface at time + of writing; both libraries evolve quickly and this example is illustrative, not covered by the + BDK's own compatibility guarantees. diff --git a/examples/ai_agent/__init__.py b/examples/ai_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/ai_agent/agent.py b/examples/ai_agent/agent.py new file mode 100644 index 00000000..b85b3bac --- /dev/null +++ b/examples/ai_agent/agent.py @@ -0,0 +1,32 @@ +"""Wires a LangGraph ReAct agent, backed by Vertex AI Gemini, with BDK tools and per-conversation memory.""" + +import os + +from langchain_google_vertexai import ChatVertexAI +from langgraph.checkpoint.memory import MemorySaver +from langgraph.prebuilt import create_react_agent + +from examples.ai_agent.tools import build_tools +from symphony.bdk.core.symphony_bdk import SymphonyBdk + +SYSTEM_PROMPT = ( + "You are a helpful assistant embedded in a Symphony chat bot. Answer concisely.\n" + "You have access to tools to look up a Symphony user by username or email, list the " + "members of the current room/IM, and send a message to an arbitrary stream.\n" + "Messages you receive are prefixed with '[thread_id=]': use that thread_id verbatim " + "when calling the room-members tool, and never repeat it in your answer." +) + + +def build_agent(bdk: SymphonyBdk): + model = ChatVertexAI( + model_name=os.environ.get("GEMINI_MODEL_NAME", "gemini-1.5-flash"), + project=os.environ["GCP_PROJECT_ID"], + location=os.environ.get("GCP_LOCATION", "us-central1"), + ) + return create_react_agent( + model, + tools=build_tools(bdk), + prompt=SYSTEM_PROMPT, + checkpointer=MemorySaver(), + ) diff --git a/examples/ai_agent/ask_ai_activity.py b/examples/ai_agent/ask_ai_activity.py new file mode 100644 index 00000000..b271c6dd --- /dev/null +++ b/examples/ai_agent/ask_ai_activity.py @@ -0,0 +1,30 @@ +from examples.ai_agent.memory_ids import thread_id +from symphony.bdk.core.activity.command import CommandActivity, CommandContext +from symphony.bdk.core.service.message.message_service import MessageService + + +class AskAiActivity(CommandActivity): + """Forwards any message addressed to the bot to the LLM agent and replies with its answer.""" + + def __init__(self, messages: MessageService, agent): + self._messages = messages + self._agent = agent + super().__init__() + + def matches(self, context: CommandContext) -> bool: + mention = "@" + context.bot_display_name + text = context.text_content.strip() + return text.startswith(mention) and text[len(mention) :].strip() != "" + + async def on_activity(self, context: CommandContext): + mention = "@" + context.bot_display_name + question = context.text_content.strip()[len(mention) :].strip() + + tid = thread_id(context.stream_id, context.initiator.user.user_id) + result = await self._agent.ainvoke( + {"messages": [("user", f"[thread_id={tid}] {question}")]}, + config={"configurable": {"thread_id": tid}}, + ) + answer = result["messages"][-1].content + + await self._messages.send_message(context.stream_id, f"{answer}") diff --git a/examples/ai_agent/main.py b/examples/ai_agent/main.py new file mode 100644 index 00000000..64784f17 --- /dev/null +++ b/examples/ai_agent/main.py @@ -0,0 +1,26 @@ +import asyncio +import logging.config +from pathlib import Path + +from examples.ai_agent.agent import build_agent +from examples.ai_agent.ask_ai_activity import AskAiActivity +from symphony.bdk.core.config.loader import BdkConfigLoader +from symphony.bdk.core.symphony_bdk import SymphonyBdk + + +async def run(): + async with SymphonyBdk(BdkConfigLoader.load_from_symphony_dir("config.yaml")) as bdk: + agent = build_agent(bdk) + bdk.activities().register(AskAiActivity(bdk.messages(), agent)) + await bdk.datafeed().start() + + +logging.config.fileConfig( + Path(__file__).parent.parent / "logging.conf", disable_existing_loggers=False +) + +try: + logging.info("Running AI agent example...") + asyncio.run(run()) +except KeyboardInterrupt: + logging.info("Ending AI agent example") diff --git a/examples/ai_agent/memory_ids.py b/examples/ai_agent/memory_ids.py new file mode 100644 index 00000000..d722b5cb --- /dev/null +++ b/examples/ai_agent/memory_ids.py @@ -0,0 +1,11 @@ +"""Builds/parses the LangGraph checkpointer thread_id from a (stream_id, user_id) pair.""" + +_SEPARATOR = "::" + + +def thread_id(stream_id: str, user_id: int) -> str: + return f"{stream_id}{_SEPARATOR}{user_id}" + + +def stream_id_from_thread(thread_id: str) -> str: + return thread_id.split(_SEPARATOR, 1)[0] diff --git a/examples/ai_agent/tools.py b/examples/ai_agent/tools.py new file mode 100644 index 00000000..97f38392 --- /dev/null +++ b/examples/ai_agent/tools.py @@ -0,0 +1,41 @@ +"""BDK-backed tools exposed to the LLM agent.""" + +from langchain_core.tools import tool + +from examples.ai_agent.memory_ids import stream_id_from_thread +from symphony.bdk.core.symphony_bdk import SymphonyBdk + + +def build_tools(bdk: SymphonyBdk): + @tool + async def lookup_user(username_or_email: str) -> str: + """Look up a Symphony user by username or email address and return their profile.""" + if "@" in username_or_email: + result = await bdk.users().list_users_by_emails([username_or_email]) + else: + result = await bdk.users().list_users_by_usernames([username_or_email]) + + if not result.users: + return f"No user found for '{username_or_email}'." + + user = result.users[0] + return ( + f"id={user.id}, username={user.username}, display_name={user.display_name}, " + f"email={user.email_address}" + ) + + @tool + async def list_current_room_members(thread_id: str) -> str: + """List the members of the room/IM the conversation identified by thread_id is happening in.""" + stream_id = stream_id_from_thread(thread_id) + members = await bdk.streams().list_room_members(stream_id) + member_ids = [str(member.id) for member in members.value] + return f"Room members (user ids): {', '.join(member_ids)}" + + @tool + async def send_message_to_stream(stream_id: str, message: str) -> str: + """Send a message to an arbitrary Symphony stream identified by stream_id.""" + await bdk.messages().send_message(stream_id, f"{message}") + return f"Message sent to stream {stream_id}." + + return [lookup_user, list_current_room_members, send_message_to_stream] diff --git a/openspec/changes/archive/2026-08-13-add-ai-agent-example/.openspec.yaml b/openspec/changes/archive/2026-08-13-add-ai-agent-example/.openspec.yaml new file mode 100644 index 00000000..b6b2d1f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-ai-agent-example/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/archive/2026-08-13-add-ai-agent-example/design.md b/openspec/changes/archive/2026-08-13-add-ai-agent-example/design.md new file mode 100644 index 00000000..c2c7193d --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-ai-agent-example/design.md @@ -0,0 +1,70 @@ +## Context + +Reference: [symphony-bdk-java `bdk-ai-agent-example`](https://github.com/finos/symphony-bdk-java/tree/main/symphony-bdk-examples/bdk-ai-agent-example). +It wires LangChain4j `AiServices` (a typed `Assistant` interface implemented at runtime) to a +Vertex AI Gemini chat model, gives it BDK-backed `@Tool` methods, and keys per-conversation memory +by `(streamId, userId)`. A `PatternCommandActivity` forwards any `@bot ` message to the +assistant and posts the answer back. + +Python BDK (`symphony/bdk/core/`) has no `PatternCommandActivity` or `AiServices`-equivalent, and +is fully async (`asyncio`). The example must be built from Python-idiomatic primitives: +`CommandActivity` (`symphony/bdk/core/activity/command.py`) for message dispatch, and an +agent/tool framework with native async + tool-calling support. + +## Goals / Non-Goals + +**Goals:** +- One runnable example under `examples/ai_agent/` closely mirroring the Java example's structure + and behavior (same 3 tools, same memory-keying scheme, same `@bot ` UX). +- Idiomatic async Python throughout — no blocking calls inside the datafeed loop. +- Self-contained: extra dependencies isolated to the example, not the core package. + +**Non-Goals:** +- No new production code in `symphony/bdk/core` or `symphony/bdk/gen`. +- Not tied to Vertex AI specifically — the model choice is illustrative, swappable. +- No persistence of conversation memory across process restarts (in-memory only, same as Java). + +## Decisions + +- **Agent framework: LangGraph (`create_react_agent`) over LangChain4j-style `AiServices`.** + Python LangChain's closest analogue to `AiServices`'s implicit tool-calling loop is LangGraph's + prebuilt `create_react_agent`, which takes a chat model + a list of tools and runs the + reason/act loop for you — no hand-rolled agent loop needed. Plain LangChain `AgentExecutor` was + considered but is legacy/deprecated in favor of LangGraph as of LangChain's own docs. +- **Model: `langchain-google-vertexai`'s `ChatVertexAI` (Gemini) over other providers.** + Matches the Java example 1:1 (same Vertex AI Gemini backend, same env vars: `GCP_PROJECT_ID`, + `GCP_LOCATION`, model name), so setup instructions and auth (`gcloud auth application-default + login`) carry over directly. +- **Memory: LangGraph checkpointer (`MemorySaver`) keyed by `thread_id` over a custom memory map.** + LangGraph's `create_react_agent` already accepts a `checkpointer` and threads state by a + `thread_id` config key — this is a drop-in replacement for the Java example's hand-rolled + `MessageWindowChatMemory` + `MemoryIds` helper. `thread_id` is built the same way as Java's + `MemoryIds.of(streamId, userId)`: `f"{stream_id}::{user_id}"`. +- **Activity: custom `CommandActivity` subclass over a new `PatternCommandActivity` base class.** + Python BDK has no regex-pattern activity base class, and adding one to core would be a + production-code change out of scope for an example. Instead, `AskAiActivity` subclasses + `CommandActivity` directly and does the `@bot ` matching itself in `matches()`, + consistent with `examples/activities/command_activity.py`'s existing pattern. +- **Tools as async functions wrapped with `@tool` over sync wrappers.** + All BDK service calls are `async def`; LangChain's `@tool` decorator supports async tool + functions natively, so tools call `await self._bdk.users().list_users_by_usernames(...)` etc. + directly without threads or sync bridging. +- **Dependencies stay out of `pyproject.toml`.** + No existing example depends on a non-BDK third-party package, and `pyproject.toml` has no + `[tool.poetry.extras]` mechanism. Adding `langgraph`/`langchain-google-vertexai` as core deps + would bloat every BDK install for one example. Instead, the example's own README documents + `pip install langgraph langchain-google-vertexai`. + +## Risks / Trade-offs + +- [LangGraph/LangChain API surface changes fast] → Pin example instructions to tested versions in + the README; example is illustrative, not covered by BDK's own compatibility guarantees. +- [In-memory-only conversation memory] → Same limitation as the Java example; acceptable for a + demo, call it out explicitly in the README. +- [GCP/Vertex AI dependency makes the example non-runnable without cloud credentials] → Matches + Java example's own trade-off; document `gcloud auth application-default login` prerequisite + clearly, same as Java's README. + +## Open Questions + +None — scope is a self-contained example, no ambiguity requiring a decision from the user. diff --git a/openspec/changes/archive/2026-08-13-add-ai-agent-example/proposal.md b/openspec/changes/archive/2026-08-13-add-ai-agent-example/proposal.md new file mode 100644 index 00000000..748d6916 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-ai-agent-example/proposal.md @@ -0,0 +1,37 @@ +## Why + +The Java BDK ships `bdk-ai-agent-example`, showing how little code turns a bot into an LLM-backed +agent with BDK tools. Python BDK has no equivalent, and it's a common ask ("how do I plug an LLM +into my bot"). Adding one closes that gap and gives users a runnable reference. + +## What Changes + +- New standalone example `examples/ai_agent/` mirroring the Java example's shape but idiomatic to + the Python BDK (async, `CommandActivity` instead of `PatternCommandActivity`, LangGraph instead + of LangChain4j `AiServices`). +- A `ChatVertexAI` (Gemini) model wired into a LangGraph ReAct-style agent (`create_react_agent`) + exposing BDK-backed tools: look up a user, list current room members, send a message to a + stream. +- Conversation memory keyed per `(stream_id, user_id)` via LangGraph's checkpointer `thread_id`, + mirroring the Java `MemoryIds` helper. +- A `CommandActivity` subclass matching any message addressed to the bot (`@BotMention `) + that forwards the text to the agent and replies with its answer. +- README documenting setup (`~/.symphony/config.yaml`, GCP ADC auth, env vars, extra pip installs) + since this example needs third-party packages not in the BDK's own dependencies. + +## Capabilities + +### New Capabilities +- `ai-agent-example`: an `examples/` module demonstrating an LLM agent (LangGraph + Vertex AI + Gemini) wired to Symphony via BDK activities, tools, and per-conversation memory. + +### Modified Capabilities +(none — no changes to existing spec-level BDK behavior) + +## Impact + +- Adds a new directory under `examples/` only; no changes to `symphony/bdk/*` production code. +- New third-party runtime dependencies for this example only (`langgraph`, + `langchain-google-vertexai`), documented in the example's own README, not added to the core + `pyproject.toml` (no existing precedent for per-example deps in this repo, per `pyproject.toml` + dependency review — extra libs will be called out with a `pip install` line instead). diff --git a/openspec/changes/archive/2026-08-13-add-ai-agent-example/specs/ai-agent-example/spec.md b/openspec/changes/archive/2026-08-13-add-ai-agent-example/specs/ai-agent-example/spec.md new file mode 100644 index 00000000..cd8038ab --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-ai-agent-example/specs/ai-agent-example/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Bot forwards addressed messages to an LLM agent +The example SHALL register an activity that matches any message addressed to the bot +(`@BotMention `) and forwards the question text to an LLM agent, then sends the agent's +answer back to the originating stream. + +#### Scenario: User asks the bot a question +- **WHEN** a user sends `@BotMention what can you do?` in a stream the bot is a member of +- **THEN** the example forwards `what can you do?` to the LLM agent and sends the agent's reply as + a message back to that stream + +### Requirement: LLM agent has BDK-backed tools +The LLM agent SHALL be given tools, implemented on top of BDK services, to look up a Symphony +user, list the members of the current room/IM, and send a message to an arbitrary stream — and +SHALL decide on its own whether and when to invoke them based on the user's question. + +#### Scenario: Question requires a user lookup +- **WHEN** the forwarded question asks the agent to find a user (e.g. "what's the email of jdoe?") +- **THEN** the agent invokes the user-lookup tool and includes the result in its answer + +#### Scenario: Question requires listing room members +- **WHEN** the forwarded question asks the agent about the members of the current room/IM +- **THEN** the agent invokes the room-members tool, scoped to the stream the question came from + +#### Scenario: Question requires sending a message elsewhere +- **WHEN** the forwarded question asks the agent to relay a message to a specific stream id +- **THEN** the agent invokes the send-message tool with that stream id and message content + +### Requirement: Conversation memory is scoped per stream and user +The example SHALL maintain separate conversation memory for each distinct `(stream_id, user_id)` +pair, so that the agent recalls prior context only for the same person in the same +room/IM, and does not mix context across different users or streams. + +#### Scenario: Same user, same stream, follow-up question +- **WHEN** a user asks a follow-up question in the same stream after a prior exchange +- **THEN** the agent's answer reflects the context of the prior exchange in that stream + +#### Scenario: Same user, different stream +- **WHEN** the same user asks a question in a different stream than a prior exchange +- **THEN** the agent treats it as a new conversation, with no memory of the other stream's + exchange + +#### Scenario: Different user, same stream +- **WHEN** a different user asks a question in a stream where another user previously talked to + the agent +- **THEN** the agent treats it as a new conversation, with no memory of the other user's exchange diff --git a/openspec/changes/archive/2026-08-13-add-ai-agent-example/tasks.md b/openspec/changes/archive/2026-08-13-add-ai-agent-example/tasks.md new file mode 100644 index 00000000..4234d255 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-add-ai-agent-example/tasks.md @@ -0,0 +1,62 @@ +## 1. Scaffolding + +- [x] 1.1 Create `examples/ai_agent/` directory +- [x] 1.2 Add `examples/ai_agent/__init__.py` (empty, matches other example folders' layout) + +## 2. Memory keying + +- [x] 2.1 Add `examples/ai_agent/memory_ids.py` with a `thread_id(stream_id, user_id)` helper + building `f"{stream_id}::{user_id}"`, and a `stream_id_from_thread(thread_id)` reverse helper for + tool calls that need the current stream + +## 3. BDK tools + +- [x] 3.1 Add `examples/ai_agent/tools.py` with a `build_tools(bdk)` function returning a list of + `@tool`-decorated async callables closing over the `SymphonyBdk` instance: + - `lookup_user(username_or_email)` using `bdk.users().list_users_by_usernames(...)` or + `list_users_by_emails(...)` depending on whether the input contains `@` + - `list_current_room_members(thread_id)` using `bdk.streams().list_room_members(...)`, resolving + the stream id from `thread_id` via `memory_ids.stream_id_from_thread` + - `send_message_to_stream(stream_id, message)` using `bdk.messages().send_message(...)`, + wrapping `message` in `` + +## 4. Agent wiring + +- [x] 4.1 Add `examples/ai_agent/agent.py` with a `build_agent(bdk)` function that constructs a + `ChatVertexAI` model (reading `GCP_PROJECT_ID`/`GCP_LOCATION`/`GEMINI_MODEL_NAME` env vars, with + defaults matching the Java example) and returns a LangGraph `create_react_agent` wired with + `build_tools(bdk)` and a `MemorySaver` checkpointer +- [x] 4.2 Include a system prompt instructing the agent to answer concisely (rendered as a chat + message) and describing the available tools, mirroring the Java `Assistant` interface's + `@SystemMessage` + +## 5. Activity + +- [x] 5.1 Add `examples/ai_agent/ask_ai_activity.py` with an `AskAiActivity(CommandActivity)` + subclass: + - `matches(context)` returns true when `context.text_content` starts with + `@{context.bot_display_name}` followed by a non-empty question + - `on_activity(context)` strips the bot mention, builds the `thread_id` from + `context.stream_id` and the initiator's user id, invokes the agent, and sends the reply via + `messages().send_message(...)` + +## 6. Entry point + +- [x] 6.1 Add `examples/ai_agent/main.py`: loads config from `~/.symphony/config.yaml` via + `BdkConfigLoader`, constructs `SymphonyBdk`, builds the agent, registers `AskAiActivity`, and + starts the datafeed loop — mirroring `examples/activities/` entry-point style + +## 7. Documentation + +- [x] 7.1 Add `examples/ai_agent/README.md` covering: what the example demonstrates, required + `pip install langgraph langchain-google-vertexai`, `gcloud auth application-default login` + prerequisite, required/optional env vars table, how to run, and a short architecture diagram + (mirroring the Java example's README) + +## 8. Verification + +- [x] 8.1 Run `poetry run ruff check examples` and `poetry run ruff format --check examples` to + confirm the new files pass lint/format +- [x] 8.2 Manually smoke-test against a real bot/config: `@BotMention what can you do?` gets a + reply, a follow-up in the same stream shows memory, a question needing a tool call (user lookup + or room members) resolves correctly diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..392946c6 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/ai-agent-example/spec.md b/openspec/specs/ai-agent-example/spec.md new file mode 100644 index 00000000..6ea972fa --- /dev/null +++ b/openspec/specs/ai-agent-example/spec.md @@ -0,0 +1,55 @@ +# ai-agent-example + +## Purpose + +TBD - capture the intent of the AI agent example (a runnable BDK example demonstrating an +LLM-backed bot that forwards addressed messages to an agent, exposes BDK-backed tools to that +agent, and maintains per-stream/per-user conversation memory). + +## Requirements + +### Requirement: Bot forwards addressed messages to an LLM agent +The example SHALL register an activity that matches any message addressed to the bot +(`@BotMention `) and forwards the question text to an LLM agent, then sends the agent's +answer back to the originating stream. + +#### Scenario: User asks the bot a question +- **WHEN** a user sends `@BotMention what can you do?` in a stream the bot is a member of +- **THEN** the example forwards `what can you do?` to the LLM agent and sends the agent's reply as + a message back to that stream + +### Requirement: LLM agent has BDK-backed tools +The LLM agent SHALL be given tools, implemented on top of BDK services, to look up a Symphony +user, list the members of the current room/IM, and send a message to an arbitrary stream — and +SHALL decide on its own whether and when to invoke them based on the user's question. + +#### Scenario: Question requires a user lookup +- **WHEN** the forwarded question asks the agent to find a user (e.g. "what's the email of jdoe?") +- **THEN** the agent invokes the user-lookup tool and includes the result in its answer + +#### Scenario: Question requires listing room members +- **WHEN** the forwarded question asks the agent about the members of the current room/IM +- **THEN** the agent invokes the room-members tool, scoped to the stream the question came from + +#### Scenario: Question requires sending a message elsewhere +- **WHEN** the forwarded question asks the agent to relay a message to a specific stream id +- **THEN** the agent invokes the send-message tool with that stream id and message content + +### Requirement: Conversation memory is scoped per stream and user +The example SHALL maintain separate conversation memory for each distinct `(stream_id, user_id)` +pair, so that the agent recalls prior context only for the same person in the same +room/IM, and does not mix context across different users or streams. + +#### Scenario: Same user, same stream, follow-up question +- **WHEN** a user asks a follow-up question in the same stream after a prior exchange +- **THEN** the agent's answer reflects the context of the prior exchange in that stream + +#### Scenario: Same user, different stream +- **WHEN** the same user asks a question in a different stream than a prior exchange +- **THEN** the agent treats it as a new conversation, with no memory of the other stream's + exchange + +#### Scenario: Different user, same stream +- **WHEN** a different user asks a question in a stream where another user previously talked to + the agent +- **THEN** the agent treats it as a new conversation, with no memory of the other user's exchange From 010a7b6de43ff23856b4cbb700a9bc97fa34a2ac Mon Sep 17 00:00:00 2001 From: Thibault Pensec Date: Thu, 13 Aug 2026 17:09:29 +0200 Subject: [PATCH 2/4] Add opsx commands and openspec skills used to author the AI agent example --- .claude/commands/opsx/apply.md | 155 ++++++++++ .claude/commands/opsx/archive.md | 160 ++++++++++ .claude/commands/opsx/explore.md | 174 +++++++++++ .claude/commands/opsx/propose.md | 109 +++++++ .claude/commands/opsx/sync.md | 143 +++++++++ .claude/skills/openspec-apply-change/SKILL.md | 159 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 117 +++++++ .claude/skills/openspec-explore/SKILL.md | 289 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 113 +++++++ .claude/skills/openspec-sync-specs/SKILL.md | 147 +++++++++ 10 files changed, 1566 insertions(+) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/commands/opsx/sync.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 .claude/skills/openspec-sync-specs/SKILL.md diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 00000000..f540757f --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,155 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.