From 74fd21f194596d65237fa9ad6c8ecf678a8073ae Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 17:47:23 +0300 Subject: [PATCH 01/90] feat(agno): add minimal Agno framework adapter Bridge a developer-built Agno Agent to Band: AgnoAdapter converts room history to Agno messages, runs the agent, and replies with its text output. Agno is model-agnostic, so the agent (model, instructions, tools) is supplied by the developer and reused per room. Includes AgnoHistoryConverter, lazy adapter/converter registration, the `agno` optional-dependency extra, and a basic example with README. Text-only skeleton: Band platform-tool wiring, tool-event conversion, conformance registration, and tests are deferred to follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/01_basic_agent.py | 84 ++++++++++ examples/agno/README.md | 86 +++++++++++ pyproject.toml | 4 + src/band/adapters/__init__.py | 6 + src/band/adapters/agno.py | 124 +++++++++++++++ src/band/converters/__init__.py | 13 ++ src/band/converters/agno.py | 60 ++++++++ uv.lock | 262 ++++++++++++++++++++++++++++---- 8 files changed, 613 insertions(+), 26 deletions(-) create mode 100644 examples/agno/01_basic_agent.py create mode 100644 examples/agno/README.md create mode 100644 src/band/adapters/agno.py create mode 100644 src/band/converters/agno.py diff --git a/examples/agno/01_basic_agent.py b/examples/agno/01_basic_agent.py new file mode 100644 index 000000000..1ad1fb991 --- /dev/null +++ b/examples/agno/01_basic_agent.py @@ -0,0 +1,84 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Basic Agno agent example. + +Builds a model-agnostic Agno agent and bridges it to the Band platform via +``AgnoAdapter``. The Agno agent owns the model, instructions, and (later) tools; +the adapter converts Band room history into Agno messages and replies with the +agent's text output. + +Requires: + - agent_config.yaml in the working directory with an `agno_agent` entry + (copy agent_config.yaml.example to agent_config.yaml and fill it in) + - BAND_WS_URL and BAND_REST_URL environment variables (the platform the + agent_config.yaml credentials belong to) + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Run with: + uv run examples/agno/01_basic_agent.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def load_environment() -> None: + """Load environment variables and validate required credentials.""" + load_dotenv() + + if not os.environ.get("ANTHROPIC_API_KEY"): + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + +async def main() -> None: + load_environment() + + ws_url = os.environ.get("BAND_WS_URL") + rest_url = os.environ.get("BAND_REST_URL") + if not ws_url: + raise ValueError("BAND_WS_URL environment variable is required") + if not rest_url: + raise ValueError("BAND_REST_URL environment variable is required") + + # Build the Agno agent — you choose the model, instructions, and tools. + agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are a helpful assistant. Be concise and friendly.", + ) + + # Bridge the Agno agent to Band. + adapter = AgnoAdapter(agno_agent) + + agent = Agent.from_config( + "agno_agent", + adapter=adapter, + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Starting Agno agent...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agno/README.md b/examples/agno/README.md new file mode 100644 index 000000000..ac6006dbb --- /dev/null +++ b/examples/agno/README.md @@ -0,0 +1,86 @@ +# Agno Examples for Band + +Examples for building Band agents with the [Agno](https://docs.agno.com) +framework. + +## Overview + +Agno is model-agnostic: you build and configure your own Agno `Agent` (model, +instructions, and — in a later iteration — tools), then bridge it to Band with +`AgnoAdapter`. The adapter converts Band room history into Agno messages, runs +your agent, and replies with its text output. + +> **Note:** This is an early, text-only integration. Band platform tools +> (`band_send_message`, etc.) are not wired into the Agno agent yet. + +## Prerequisites + +1. **Anthropic API Key** - Set `ANTHROPIC_API_KEY` (or add it to a `.env` file) +2. **Band Platform** - Create a remote agent and get credentials, and set + `BAND_WS_URL` / `BAND_REST_URL` to the platform those credentials belong to +3. **Dependencies** - Install with `uv sync --extra agno` + +--- + +## Quick Start + +```python +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude + +from band import Agent +from band.adapters import AgnoAdapter + +# You own the Agno agent — model, instructions, tools. +agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are a helpful assistant. Be concise and friendly.", +) + +# Bridge it to Band. +adapter = AgnoAdapter(agno_agent) +agent = Agent.from_config("agno_agent", adapter=adapter) +await agent.run() +``` + +--- + +## Examples + +| File | Description | +|------|-------------| +| `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | + +--- + +## Running Examples + +```bash +# From repository root +cp examples/agno/agent_config.yaml.example agent_config.yaml +# edit agent_config.yaml with your Band agent_id + api_key + +uv run examples/agno/01_basic_agent.py +``` + +`Agent.from_config` looks for `agent_config.yaml` in the current working +directory, so run from the directory that contains it. + +--- + +## Configuration + +Add your agent credentials to `agent_config.yaml`: + +```yaml +agno_agent: + agent_id: "your-agent-id" + api_key: "your-band-api-key" +``` + +Provide your Anthropic API key via environment variable or a `.env` file in the +repository root: + +```bash +ANTHROPIC_API_KEY=your-anthropic-api-key +``` diff --git a/pyproject.toml b/pyproject.toml index d09d65d89..1c7915cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,10 @@ agentcore_runtime = [ google_adk = [ "google-adk>=1.0.0,<2", ] +agno = [ + "agno>=2.6.0", + "anthropic>=0.75.0", # Claude model provider for agno +] # dev extra includes ALL framework deps for testing EXCEPT crewai, # which conflicts with both parlant and pydantic-ai (see tool.uv.conflicts). diff --git a/src/band/adapters/__init__.py b/src/band/adapters/__init__.py index 204c0a713..7e3823b5f 100644 --- a/src/band/adapters/__init__.py +++ b/src/band/adapters/__init__.py @@ -42,6 +42,7 @@ ACPServer as ACPServer, BandACPServerAdapter as BandACPServerAdapter, ) + from band.adapters.agno import AgnoAdapter as AgnoAdapter from band.adapters.gemini import GeminiAdapter as GeminiAdapter from band.adapters.google_adk import GoogleADKAdapter as GoogleADKAdapter from band.adapters.opencode import OpencodeAdapter as OpencodeAdapter @@ -67,6 +68,7 @@ "ACPClientAdapter", "ACPServer", "BandACPServerAdapter", + "AgnoAdapter", "GeminiAdapter", "GoogleADKAdapter", "OpencodeAdapter", @@ -141,6 +143,10 @@ def __getattr__(name: str) -> type: elif name == "ACPServer": return ACPServer return BandACPServerAdapter + elif name == "AgnoAdapter": + from band.adapters.agno import AgnoAdapter + + return AgnoAdapter elif name == "GeminiAdapter": from band.adapters.gemini import GeminiAdapter diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py new file mode 100644 index 000000000..6bd2b5142 --- /dev/null +++ b/src/band/adapters/agno.py @@ -0,0 +1,124 @@ +""" +Agno adapter using the SimpleAdapter pattern. + +Agno is model-agnostic: the developer builds and configures their own Agno +``Agent`` (model, instructions, tools, reasoning, ...) and hands it to this +adapter. The adapter simply bridges it to Band — it converts Band history to +Agno messages, runs the developer's agent, and sends the text reply back. + +Unlike adapters that run an explicit tool-calling loop, Agno owns its own agent +loop internally: ``Agent.arun(input=...)`` accepts a list of Agno messages and +returns a run output whose ``.content`` is the final text. This adapter is a +text-only skeleton — Band platform tools are not wired into the Agno agent yet. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, ClassVar + +from band.core.protocols import AgentToolsProtocol +from band.core.simple_adapter import SimpleAdapter +from band.core.types import ( + AdapterFeatures, + Capability, + Emit, + PlatformMessage, +) +from band.converters.agno import AgnoHistoryConverter, AgnoMessages + +if TYPE_CHECKING: + from agno.agent import Agent as AgnoAgent + +logger = logging.getLogger(__name__) + + +class AgnoAdapter(SimpleAdapter[AgnoMessages]): + """ + Agno framework adapter (text-only skeleton). + + Takes a developer-built Agno ``Agent`` and bridges it to Band. Stateless per + room: Band history is the source of truth and is passed as input on every + message. No Band platform tools are wired into the Agno agent yet. + + Example: + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are a helpful assistant.", + ) + adapter = AgnoAdapter(agno_agent) + agent = Agent.create(adapter=adapter, agent_id="...", api_key="...") + await agent.run() + """ + + # Skeleton: no execution events emitted, no tool capabilities yet. + SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset() + SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset() + + def __init__( + self, + agent: AgnoAgent, + *, + history_converter: AgnoHistoryConverter | None = None, + features: AdapterFeatures | None = None, + ) -> None: + super().__init__( + history_converter=history_converter or AgnoHistoryConverter(), + features=features, + ) + + # The developer's Agno agent; reused across rooms/messages. Agno keeps + # per-run state in its run context, so a single instance is safe to + # reuse (Band history is passed as input on every call). + self.agent = agent + + async def on_started(self, agent_name: str, agent_description: str) -> None: + """Sync the converter's identity with the Band agent name.""" + await super().on_started(agent_name, agent_description) + + # Keep the converter's own-agent filtering in sync with our identity. + if isinstance(self.history_converter, AgnoHistoryConverter): + self.history_converter.set_agent_name(agent_name) + + logger.info("Agno adapter started for agent: %s", agent_name) + + async def on_message( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: AgnoMessages, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> None: + """Run the developer's Agno agent on the history and reply with text.""" + from agno.models.message import Message + + # Band history is the source of truth; build the input fresh each call. + messages: list[Message] = list(history) + if participants_msg: + messages.append( + Message(role="user", content=f"[System]: {participants_msg}") + ) + if contacts_msg: + messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) + messages.append(Message(role="user", content=msg.format_for_llm())) + + try: + response = await self.agent.arun(input=messages) + except Exception as e: + logger.exception("Error running Agno agent in room %s: %s", room_id, e) + raise + + text = (response.content or "").strip() if response is not None else "" + if not text: + logger.debug("Room %s: Agno agent returned empty content", room_id) + return + + mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] + await tools.send_message(text, mentions=mention) diff --git a/src/band/converters/__init__.py b/src/band/converters/__init__.py index 291475a70..94a21c397 100644 --- a/src/band/converters/__init__.py +++ b/src/band/converters/__init__.py @@ -64,6 +64,10 @@ from band.converters.acp_client import ( ACPClientHistoryConverter as ACPClientHistoryConverter, ) + from band.converters.agno import ( + AgnoHistoryConverter as AgnoHistoryConverter, + AgnoMessages as AgnoMessages, + ) from band.converters.gemini import ( GeminiHistoryConverter as GeminiHistoryConverter, GeminiMessages as GeminiMessages, @@ -95,6 +99,8 @@ "CodexHistoryConverter", "ACPServerHistoryConverter", "ACPClientHistoryConverter", + "AgnoHistoryConverter", + "AgnoMessages", "GeminiHistoryConverter", "GeminiMessages", "GoogleADKHistoryConverter", @@ -183,6 +189,13 @@ def __getattr__(name: str) -> type: from band.converters.codex import CodexHistoryConverter return CodexHistoryConverter + elif name in ("AgnoHistoryConverter", "AgnoMessages"): + from band.converters.agno import AgnoHistoryConverter, AgnoMessages + + if name == "AgnoHistoryConverter": + return AgnoHistoryConverter + return AgnoMessages + elif name in ("GeminiHistoryConverter", "GeminiMessages"): from band.converters.gemini import GeminiHistoryConverter, GeminiMessages diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py new file mode 100644 index 000000000..cc72174f8 --- /dev/null +++ b/src/band/converters/agno.py @@ -0,0 +1,60 @@ +"""Agno history converter.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from band.core.protocols import HistoryConverter + +if TYPE_CHECKING: + from agno.models.message import Message + +logger = logging.getLogger(__name__) + +# Forward-referenced so this module imports without agno installed; the real +# Message type is imported lazily inside convert(). +AgnoMessages = list["Message"] + + +class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): + """ + Convert platform history to Agno message format. + + Output (text-only skeleton): + - this agent's text messages -> Message(role="assistant", content=...) + - everyone else's text messages -> Message(role="user", content="[name]: ...") + + NOTE: tool_call / tool_result / thought events are skipped for now. Tool-event + conversion lands together with Band platform-tool wiring in a follow-up. + """ + + def __init__(self, agent_name: str = ""): + self._agent_name = agent_name + + def set_agent_name(self, name: str) -> None: + self._agent_name = name + + def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: + """Convert platform history to Agno messages.""" + from agno.models.message import Message + + messages: list[Message] = [] + + for hist in raw: + message_type = hist.get("message_type", "text") + if message_type != "text": + # Skip tool_call / tool_result / thought events for now. + continue + + content = hist.get("content", "") + role = hist.get("role", "user") + sender_name = hist.get("sender_name", "") + + if role == "assistant" and sender_name == self._agent_name: + messages.append(Message(role="assistant", content=content)) + else: + formatted = f"[{sender_name}]: {content}" if sender_name else content + messages.append(Message(role="user", content=formatted)) + + return messages diff --git a/uv.lock b/uv.lock index fcddb8d59..95c9fc9be 100644 --- a/uv.lock +++ b/uv.lock @@ -135,6 +135,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] +[[package]] +name = "agno" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "h11" }, + { name = "httpx", extra = ["http2"] }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings", version = "2.10.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "pydantic-settings", version = "2.13.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "typer", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "typer", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/08/99d70ea99b95aaeb0e98c1cffa6c19ee11fd43c919a74bc020ec671c18b7/agno-2.6.16.tar.gz", hash = "sha256:cc938d16e4ab0dcf3bd97c40867908eb8cefe034e6ee2082b3398cf01e1913bd", size = 2148053, upload-time = "2026-06-15T20:56:30.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f0/630d3ebaa44b5f61b2a11eb22a81231eea22650b3fd04583d57f0fe1587b/agno-2.6.16-py3-none-any.whl", hash = "sha256:86a0e1090d34aa3248983dbd57a277a9124c1a7f2e065dadc45f15e8dc9eacb0", size = 2536449, upload-time = "2026-06-15T20:56:28.641Z" }, +] + [[package]] name = "aiofile" version = "3.9.0" @@ -510,6 +537,10 @@ agentcore-runtime = [ { name = "fastapi" }, { name = "uvicorn" }, ] +agno = [ + { name = "agno" }, + { name = "anthropic" }, +] anthropic = [ { name = "anthropic" }, ] @@ -642,10 +673,12 @@ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'dev'", specifier = ">=0.3.22" }, { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0" }, { name = "agent-client-protocol", marker = "extra == 'dev'", specifier = ">=0.9.0" }, + { name = "agno", marker = "extra == 'agno'", specifier = ">=2.6.0" }, { name = "aiohttp", marker = "extra == 'bridge'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'bridge-agentcore'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'dev'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'slack'", specifier = ">=3.9,<4" }, + { name = "anthropic", marker = "extra == 'agno'", specifier = ">=0.75.0" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.75.0" }, { name = "anthropic", marker = "extra == 'dev'", specifier = ">=0.75.0" }, { name = "band-client-rest", specifier = "==0.0.10" }, @@ -750,7 +783,7 @@ requires-dist = [ { name = "werkzeug", marker = "extra == 'dev'", specifier = ">=3.1.6" }, { name = "werkzeug", marker = "extra == 'parlant'", specifier = ">=3.1.6" }, ] -provides-extras = ["codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "dev", "dev-crewai"] +provides-extras = ["codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "agno", "dev", "dev-crewai"] [[package]] name = "bcrypt" @@ -2046,6 +2079,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + [[package]] name = "google-adk" version = "1.10.0" @@ -2937,6 +2994,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.4.3" @@ -2969,6 +3039,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -3045,6 +3124,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -3087,6 +3171,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -4241,12 +4334,18 @@ resolution-markers = [ "python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version == '3.13.*' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version < '3.13' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] dependencies = [ - { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra != 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -7778,20 +7877,71 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, - { name = "pygments", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "pygments", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -8144,6 +8294,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -8540,21 +8699,72 @@ name = "typer" version = "0.24.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'emscripten'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] dependencies = [ - { name = "annotated-doc", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "shellingham", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "annotated-doc", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "shellingham", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ From 508855cb9ebca17cab1137f9384f7cdeef9c9aef Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 17:50:01 +0300 Subject: [PATCH 02/90] docs(agno): fix dangling agent_config.yaml.example references Add an agno_agent entry to the repo-root agent_config.yaml.example and repoint the example docstring and README at it, instead of a per-example config file that does not exist. Simplify the example's platform URL handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_config.yaml.example | 9 +++++++++ examples/agno/01_basic_agent.py | 14 ++++---------- examples/agno/README.md | 4 ++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/agent_config.yaml.example b/agent_config.yaml.example index ca19550a6..b55f47a15 100644 --- a/agent_config.yaml.example +++ b/agent_config.yaml.example @@ -167,6 +167,15 @@ gemini_agent: agent_id: "" api_key: "" +# ============================================================================= +# Agno Examples +# ============================================================================= + +# 01_basic_agent.py +agno_agent: + agent_id: "" + api_key: "" + # Google ADK Examples # ============================================================================= diff --git a/examples/agno/01_basic_agent.py b/examples/agno/01_basic_agent.py index 1ad1fb991..359ea42e0 100644 --- a/examples/agno/01_basic_agent.py +++ b/examples/agno/01_basic_agent.py @@ -15,7 +15,8 @@ Requires: - agent_config.yaml in the working directory with an `agno_agent` entry - (copy agent_config.yaml.example to agent_config.yaml and fill it in) + (copy the repo-root agent_config.yaml.example to agent_config.yaml and + fill in the agno_agent credentials) - BAND_WS_URL and BAND_REST_URL environment variables (the platform the agent_config.yaml credentials belong to) - ANTHROPIC_API_KEY environment variable (for the Claude model) @@ -53,13 +54,6 @@ def load_environment() -> None: async def main() -> None: load_environment() - ws_url = os.environ.get("BAND_WS_URL") - rest_url = os.environ.get("BAND_REST_URL") - if not ws_url: - raise ValueError("BAND_WS_URL environment variable is required") - if not rest_url: - raise ValueError("BAND_REST_URL environment variable is required") - # Build the Agno agent — you choose the model, instructions, and tools. agno_agent = AgnoAgent( model=Claude(id="claude-sonnet-4-6"), @@ -72,8 +66,8 @@ async def main() -> None: agent = Agent.from_config( "agno_agent", adapter=adapter, - ws_url=ws_url, - rest_url=rest_url, + ws_url=os.environ.get("BAND_WS_URL"), + rest_url=os.environ.get("BAND_REST_URL"), ) logger.info("Starting Agno agent...") diff --git a/examples/agno/README.md b/examples/agno/README.md index ac6006dbb..c65f823ca 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -57,8 +57,8 @@ await agent.run() ```bash # From repository root -cp examples/agno/agent_config.yaml.example agent_config.yaml -# edit agent_config.yaml with your Band agent_id + api_key +cp agent_config.yaml.example agent_config.yaml +# edit the agno_agent entry in agent_config.yaml with your Band agent_id + api_key uv run examples/agno/01_basic_agent.py ``` From 577d53df1e1853083945c295602983946237b6f0 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 18:00:10 +0300 Subject: [PATCH 03/90] fix(agno): serialize non-text Agno output via get_content_as_string Use RunOutput.get_content_as_string() instead of assuming response.content is a str, so agents configured with structured output (Pydantic BaseModel) or dict/list output are serialized to JSON rather than crashing on .strip(). Log at debug when non-text content is sent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 6bd2b5142..8b3a83ce7 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -115,10 +115,22 @@ async def on_message( logger.exception("Error running Agno agent in room %s: %s", room_id, e) raise - text = (response.content or "").strip() if response is not None else "" + if response is None: + return + + # get_content_as_string() handles str, structured (BaseModel -> JSON), + # and dict/list output uniformly. + text = response.get_content_as_string().strip() if not text: logger.debug("Room %s: Agno agent returned empty content", room_id) return + if response.content_type not in ("str", ""): + logger.debug( + "Room %s: Agno returned %s output; sending JSON-serialized form", + room_id, + response.content_type, + ) + mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] await tools.send_message(text, mentions=mention) From c956cebe7d04a96090201a10fcd061831596e5ec Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 18:06:48 +0300 Subject: [PATCH 04/90] feat(agno): support Emit.EXECUTION tool reporting Declare SUPPORTED_EMIT={Emit.EXECUTION} and, when enabled via AdapterFeatures(emit={Emit.EXECUTION}), report the Agno agent's own tool executions to the room as tool_call/tool_result events after each run, read from RunOutput.tools. Mirrors the event shape used by the other adapters. Reporting failures are logged, not raised. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 58 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 8b3a83ce7..9ee67a927 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -14,6 +14,7 @@ from __future__ import annotations +import json import logging from typing import TYPE_CHECKING, ClassVar @@ -29,17 +30,20 @@ if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent + from agno.run.agent import RunOutput logger = logging.getLogger(__name__) class AgnoAdapter(SimpleAdapter[AgnoMessages]): """ - Agno framework adapter (text-only skeleton). + Agno framework adapter (text output + execution reporting). Takes a developer-built Agno ``Agent`` and bridges it to Band. Stateless per room: Band history is the source of truth and is passed as input on every - message. No Band platform tools are wired into the Agno agent yet. + message. Band platform tools are not wired into the Agno agent yet, but when + ``Emit.EXECUTION`` is enabled the agent's own tool executions are reported to + the room as tool_call/tool_result events. Example: from agno.agent import Agent as AgnoAgent @@ -54,8 +58,9 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): await agent.run() """ - # Skeleton: no execution events emitted, no tool capabilities yet. - SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset() + # Can report the Agno agent's own tool executions to the room. + SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION}) + # No Band platform-tool capabilities wired yet. SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset() def __init__( @@ -118,6 +123,11 @@ async def on_message( if response is None: return + # Report the agent's own tool executions (happened during the run, so + # before the final reply) when execution reporting is enabled. + if Emit.EXECUTION in self.features.emit: + await self._report_tool_executions(response, tools, room_id) + # get_content_as_string() handles str, structured (BaseModel -> JSON), # and dict/list output uniformly. text = response.get_content_as_string().strip() @@ -134,3 +144,43 @@ async def on_message( mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] await tools.send_message(text, mentions=mention) + + async def _report_tool_executions( + self, + response: RunOutput, + tools: AgentToolsProtocol, + room_id: str, + ) -> None: + """Emit tool_call/tool_result events for the agent's tool executions.""" + for te in getattr(response, "tools", None) or []: + tool_call_id = getattr(te, "tool_call_id", None) or "" + tool_name = getattr(te, "tool_name", None) or "" + try: + await tools.send_event( + content=json.dumps( + { + "name": tool_name, + "args": getattr(te, "tool_args", None) or {}, + "tool_call_id": tool_call_id, + } + ), + message_type="tool_call", + ) + await tools.send_event( + content=json.dumps( + { + "name": tool_name, + "output": str(getattr(te, "result", "") or ""), + "tool_call_id": tool_call_id, + "is_error": bool(getattr(te, "tool_call_error", False)), + } + ), + message_type="tool_result", + ) + except Exception as e: + logger.warning( + "Room %s: failed to report tool execution %s: %s", + room_id, + tool_name, + e, + ) From a98d0dd79552946844a67b332c24ec74090f0477 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 18:09:04 +0300 Subject: [PATCH 05/90] docs(agno): add tool-execution reporting example 02_tool_reporting.py builds an Agno agent with its own tool and enables AdapterFeatures(emit={Emit.EXECUTION}) so tool_call/tool_result events are posted to the room. Listed in the examples README. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/02_tool_reporting.py | 89 ++++++++++++++++++++++++++++++ examples/agno/README.md | 1 + 2 files changed, 90 insertions(+) create mode 100644 examples/agno/02_tool_reporting.py diff --git a/examples/agno/02_tool_reporting.py b/examples/agno/02_tool_reporting.py new file mode 100644 index 000000000..721674aac --- /dev/null +++ b/examples/agno/02_tool_reporting.py @@ -0,0 +1,89 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Agno agent with tool-execution reporting. + +Builds an Agno agent that has its own tools, and enables Band execution +reporting via ``AdapterFeatures(emit={Emit.EXECUTION})``. Whenever the Agno +agent calls one of its tools, the adapter posts tool_call/tool_result events to +the room so the tool activity is visible in Band. + +Requires: + - agent_config.yaml in the working directory with an `agno_agent` entry + (copy the repo-root agent_config.yaml.example to agent_config.yaml and + fill in the agno_agent credentials) + - BAND_WS_URL and BAND_REST_URL environment variables (the platform the + agent_config.yaml credentials belong to) + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Run with: + uv run examples/agno/02_tool_reporting.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter +from band.core.types import AdapterFeatures, Emit + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + # A real tool would call a weather API; this is a stub for the example. + return f"It is 22°C and sunny in {city}." + + +def load_environment() -> None: + """Load environment variables and validate required credentials.""" + load_dotenv() + + if not os.environ.get("ANTHROPIC_API_KEY"): + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + +async def main() -> None: + load_environment() + + # The Agno agent owns its tools; the adapter reports their executions. + agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are a helpful assistant. Use tools when relevant.", + tools=[get_weather], + ) + + # emit={Emit.EXECUTION} posts tool_call/tool_result events to the room. + adapter = AgnoAdapter( + agno_agent, + features=AdapterFeatures(emit={Emit.EXECUTION}), + ) + + agent = Agent.from_config( + "agno_agent", + adapter=adapter, + ws_url=os.environ.get("BAND_WS_URL"), + rest_url=os.environ.get("BAND_REST_URL"), + ) + + logger.info("Starting Agno agent with tool reporting...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agno/README.md b/examples/agno/README.md index c65f823ca..79db72d35 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -50,6 +50,7 @@ await agent.run() | File | Description | |------|-------------| | `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | +| `02_tool_reporting.py` | **Tool-execution reporting** - An Agno agent with its own tools; `AdapterFeatures(emit={Emit.EXECUTION})` posts tool_call/tool_result events to the room. | --- From 0c137c41a1406b35e5aadf8aa4d589bf012cbf12 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 18:59:55 +0300 Subject: [PATCH 06/90] chore(agno): add flow logging to on_message Log the message-handling flow at INFO (handling message, reporting tool executions, sending reply) with verbose details (input size, tool args/ results, edge cases) at DEBUG. Verified live against the platform. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 9ee67a927..6862fda78 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,8 +8,9 @@ Unlike adapters that run an explicit tool-calling loop, Agno owns its own agent loop internally: ``Agent.arun(input=...)`` accepts a list of Agno messages and -returns a run output whose ``.content`` is the final text. This adapter is a -text-only skeleton — Band platform tools are not wired into the Agno agent yet. +returns a run output whose ``.content`` is the final text. Band platform tools +are not wired into the Agno agent yet, but the agent's own tool executions are +reported to the room when ``Emit.EXECUTION`` is enabled. """ from __future__ import annotations @@ -104,6 +105,9 @@ async def on_message( """Run the developer's Agno agent on the history and reply with text.""" from agno.models.message import Message + sender = msg.sender_name or msg.sender_type + logger.info("Room %s: handling message from %s", room_id, sender) + # Band history is the source of truth; build the input fresh each call. messages: list[Message] = list(history) if participants_msg: @@ -114,6 +118,9 @@ async def on_message( messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) messages.append(Message(role="user", content=msg.format_for_llm())) + logger.debug( + "Room %s: running Agno agent (%d input messages)", room_id, len(messages) + ) try: response = await self.agent.arun(input=messages) except Exception as e: @@ -121,6 +128,7 @@ async def on_message( raise if response is None: + logger.debug("Room %s: Agno agent returned no response", room_id) return # Report the agent's own tool executions (happened during the run, so @@ -143,6 +151,7 @@ async def on_message( ) mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] + logger.info("Room %s: sending reply (%d chars)", room_id, len(text)) await tools.send_message(text, mentions=mention) async def _report_tool_executions( @@ -152,15 +161,31 @@ async def _report_tool_executions( room_id: str, ) -> None: """Emit tool_call/tool_result events for the agent's tool executions.""" - for te in getattr(response, "tools", None) or []: + executions = list(getattr(response, "tools", None) or []) + if not executions: + return + + logger.info("Room %s: reporting %d tool execution(s)", room_id, len(executions)) + for te in executions: tool_call_id = getattr(te, "tool_call_id", None) or "" tool_name = getattr(te, "tool_name", None) or "" + tool_args = getattr(te, "tool_args", None) or {} + is_error = bool(getattr(te, "tool_call_error", False)) + result = str(getattr(te, "result", "") or "") + logger.debug( + "Room %s: tool %s(%s) -> %s%s", + room_id, + tool_name, + tool_args, + result[:200], + " [error]" if is_error else "", + ) try: await tools.send_event( content=json.dumps( { "name": tool_name, - "args": getattr(te, "tool_args", None) or {}, + "args": tool_args, "tool_call_id": tool_call_id, } ), @@ -170,9 +195,9 @@ async def _report_tool_executions( content=json.dumps( { "name": tool_name, - "output": str(getattr(te, "result", "") or ""), + "output": result, "tool_call_id": tool_call_id, - "is_error": bool(getattr(te, "tool_call_error", False)), + "is_error": is_error, } ), message_type="tool_result", From abc76faad585349b69c3a6839d4dc773e0440513 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 19:11:50 +0300 Subject: [PATCH 07/90] feat(agno): expose Band memory/contact tools to the agent Declare SUPPORTED_CAPABILITIES={MEMORY, CONTACTS} and, when those capabilities are enabled, wire Band's memory/contact tools into the Agno agent as Agno Functions. Each tool's entrypoint runs the Band tool via execute_tool_call, binding the per-room tools handle through a ContextVar so a single shared agent serves concurrent rooms. Base tools (send_message, participants) stay adapter-driven and are filtered out. Tools are wired once on the first message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 116 +++++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 6862fda78..a2186c4be 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,16 +8,18 @@ Unlike adapters that run an explicit tool-calling loop, Agno owns its own agent loop internally: ``Agent.arun(input=...)`` accepts a list of Agno messages and -returns a run output whose ``.content`` is the final text. Band platform tools -are not wired into the Agno agent yet, but the agent's own tool executions are -reported to the room when ``Emit.EXECUTION`` is enabled. +returns a run output whose ``.content`` is the final text. When the matching +capabilities are enabled, Band's memory/contact tools are wired into the Agno +agent so the model can call them, and the agent's tool executions are reported +to the room when ``Emit.EXECUTION`` is enabled. """ from __future__ import annotations import json import logging -from typing import TYPE_CHECKING, ClassVar +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, ClassVar from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter @@ -32,9 +34,31 @@ if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent from agno.run.agent import RunOutput + from agno.tools.function import Function logger = logging.getLogger(__name__) +# The Band tools handle for the room being processed. Wired Band tools read it +# at call time so a single shared Agno agent can serve concurrent rooms — each +# on_message coroutine sets its own value (ContextVars are task-isolated). +_current_tools: ContextVar[AgentToolsProtocol | None] = ContextVar( + "agno_current_tools", default=None +) + + +def _make_band_entrypoint(tool_name: str) -> Any: + """Build an async Agno tool entrypoint that runs a Band platform tool.""" + + async def _entrypoint(**kwargs: Any) -> str: + active = _current_tools.get() + if active is None: + return f"Error: no active Band context for tool {tool_name}" + result = await active.execute_tool_call(tool_name, kwargs) + return result if isinstance(result, str) else json.dumps(result, default=str) + + _entrypoint.__name__ = tool_name + return _entrypoint + class AgnoAdapter(SimpleAdapter[AgnoMessages]): """ @@ -42,9 +66,10 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): Takes a developer-built Agno ``Agent`` and bridges it to Band. Stateless per room: Band history is the source of truth and is passed as input on every - message. Band platform tools are not wired into the Agno agent yet, but when - ``Emit.EXECUTION`` is enabled the agent's own tool executions are reported to - the room as tool_call/tool_result events. + message. Band's memory/contact tools are exposed to the agent when the + matching capabilities are enabled, and the agent's tool executions are + reported to the room as tool_call/tool_result events when ``Emit.EXECUTION`` + is enabled. Example: from agno.agent import Agent as AgnoAgent @@ -61,8 +86,10 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): # Can report the Agno agent's own tool executions to the room. SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION}) - # No Band platform-tool capabilities wired yet. - SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset() + # Can expose Band memory/contact tools to the Agno agent. + SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( + {Capability.MEMORY, Capability.CONTACTS} + ) def __init__( self, @@ -81,6 +108,10 @@ def __init__( # reuse (Band history is passed as input on every call). self.agent = agent + # Band capability tools (memory/contacts) are wired into the agent once, + # on the first message, since they are room-agnostic. + self._band_tools_wired = False + async def on_started(self, agent_name: str, agent_description: str) -> None: """Sync the converter's identity with the Band agent name.""" await super().on_started(agent_name, agent_description) @@ -108,6 +139,9 @@ async def on_message( sender = msg.sender_name or msg.sender_type logger.info("Room %s: handling message from %s", room_id, sender) + # Expose Band memory/contact tools to the agent (once, room-agnostic). + self._ensure_band_tools(tools) + # Band history is the source of truth; build the input fresh each call. messages: list[Message] = list(history) if participants_msg: @@ -121,11 +155,15 @@ async def on_message( logger.debug( "Room %s: running Agno agent (%d input messages)", room_id, len(messages) ) + # Bind the room's tools so wired Band tools execute against this room. + token = _current_tools.set(tools) try: response = await self.agent.arun(input=messages) except Exception as e: logger.exception("Error running Agno agent in room %s: %s", room_id, e) raise + finally: + _current_tools.reset(token) if response is None: logger.debug("Room %s: Agno agent returned no response", room_id) @@ -154,6 +192,66 @@ async def on_message( logger.info("Room %s: sending reply (%d chars)", room_id, len(text)) await tools.send_message(text, mentions=mention) + def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: + """Wire Band memory/contact tools into the Agno agent once. + + These tools are room-agnostic (the active room is supplied via the + ``_current_tools`` ContextVar at call time), so they are added to the + shared agent a single time on the first message. + """ + if self._band_tools_wired: + return + + band_tools = self._build_band_tools(tools) + for fn in band_tools: + self.agent.add_tool(fn) + if band_tools: + logger.info( + "Wired %d Band capability tool(s) into Agno agent: %s", + len(band_tools), + ", ".join(t.name for t in band_tools), + ) + # Synchronous, no await: safe to mark wired even across concurrent calls. + self._band_tools_wired = True + + def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: + """Convert the capability-gated Band tool schemas into Agno Functions.""" + from agno.tools.function import Function + + include_memory = Capability.MEMORY in self.features.capabilities + include_contacts = Capability.CONTACTS in self.features.capabilities + if not (include_memory or include_contacts): + return [] + + # The base tools (send_message, participants, ...) are driven by the + # adapter itself; expose only the capability-gated memory/contact tools. + base_names = { + schema["function"]["name"] + for schema in tools.get_openai_tool_schemas( + include_memory=False, include_contacts=False + ) + } + + band_tools: list[Function] = [] + for schema in tools.get_openai_tool_schemas( + include_memory=include_memory, include_contacts=include_contacts + ): + fn = schema.get("function", {}) + name = fn.get("name") + if not name or name in base_names: + continue + band_tools.append( + Function( + name=name, + description=fn.get("description", "") or "", + parameters=fn.get("parameters") + or {"type": "object", "properties": {}}, + entrypoint=_make_band_entrypoint(name), + skip_entrypoint_processing=True, + ) + ) + return band_tools + async def _report_tool_executions( self, response: RunOutput, From db9e7038c950ccc00db25c996405762fedbe66c2 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 19:17:33 +0300 Subject: [PATCH 08/90] refactor(agno): run a deep copy of the caller's agent Deep-copy the provided Agno agent in on_started and wire Band tools onto the copy, so the adapter never mutates the caller's agent object. Guard on_message for the not-yet-started case and handle add_tool's callable-factory rejection. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index a2186c4be..e321a3de6 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -103,19 +103,25 @@ def __init__( features=features, ) - # The developer's Agno agent; reused across rooms/messages. Agno keeps - # per-run state in its run context, so a single instance is safe to - # reuse (Band history is passed as input on every call). - self.agent = agent - - # Band capability tools (memory/contacts) are wired into the agent once, + # The caller's agent is the source of configuration. We never mutate it: + # on_started builds a deep copy (`self.agent`) that we wire Band tools + # into and run. The copy is shared across rooms/messages; Agno keeps + # per-run state in its run context and Band history is passed as input + # on every call, so a single instance is safe to reuse. + self._source_agent = agent + self.agent: AgnoAgent | None = None + + # Band capability tools (memory/contacts) are wired into the copy once, # on the first message, since they are room-agnostic. self._band_tools_wired = False async def on_started(self, agent_name: str, agent_description: str) -> None: - """Sync the converter's identity with the Band agent name.""" + """Deep-copy the caller's agent and sync the converter identity.""" await super().on_started(agent_name, agent_description) + # Run a copy so wiring Band tools never mutates the caller's object. + self.agent = self._source_agent.deep_copy() + # Keep the converter's own-agent filtering in sync with our identity. if isinstance(self.history_converter, AgnoHistoryConverter): self.history_converter.set_agent_name(agent_name) @@ -136,6 +142,9 @@ async def on_message( """Run the developer's Agno agent on the history and reply with text.""" from agno.models.message import Message + if self.agent is None: + raise RuntimeError("Agno agent not initialized; on_started was not called") + sender = msg.sender_name or msg.sender_type logger.info("Room %s: handling message from %s", room_id, sender) @@ -199,17 +208,23 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: ``_current_tools`` ContextVar at call time), so they are added to the shared agent a single time on the first message. """ - if self._band_tools_wired: + if self._band_tools_wired or self.agent is None: return band_tools = self._build_band_tools(tools) + wired: list[str] = [] for fn in band_tools: - self.agent.add_tool(fn) - if band_tools: + try: + self.agent.add_tool(fn) + wired.append(fn.name) + except RuntimeError as e: + # add_tool rejects when the agent's tools is a callable factory. + logger.warning("Could not wire Band tool %s: %s", fn.name, e) + if wired: logger.info( "Wired %d Band capability tool(s) into Agno agent: %s", - len(band_tools), - ", ".join(t.name for t in band_tools), + len(wired), + ", ".join(wired), ) # Synchronous, no await: safe to mark wired even across concurrent calls. self._band_tools_wired = True From f25a6bd6862150d1522a5a808bd01c3eb9ed4e72 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 16 Jun 2026 19:21:07 +0300 Subject: [PATCH 09/90] feat(agno): warn when Band memory collides with Agno's own memory When the caller enables Capability.MEMORY (so the adapter exposes Band memory tools to the agent) and the Agno agent also manages its own memory (update_memory_on_run or enable_agentic_memory), raise a UserWarning at construction time (stacklevel points at the caller). Using warnings.warn rather than logging makes the conflict filterable and escalatable to an error. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index e321a3de6..a9157b6d4 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -18,6 +18,7 @@ import json import logging +import warnings from contextvars import ContextVar from typing import TYPE_CHECKING, Any, ClassVar @@ -115,6 +116,33 @@ def __init__( # on the first message, since they are room-agnostic. self._band_tools_wired = False + self._warn_on_memory_collision(agent) + + def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: + """Warn if Band memory was requested while Agno's own memory is enabled. + + Only relevant when the caller enabled ``Capability.MEMORY``: the adapter + then exposes Band memory tools to the agent, which collides with Agno's + built-in memory (``update_memory_on_run`` / ``enable_agentic_memory``). + """ + if Capability.MEMORY not in self.features.capabilities: + return + + enabled: list[str] = [] + if agent.update_memory_on_run: + enabled.append("update_memory_on_run") + if agent.enable_agentic_memory: + enabled.append("enable_agentic_memory") + + if enabled: + warnings.warn( + "Capability.MEMORY exposes Band memory tools to the agent, but " + f"this Agno agent also manages its own memory ({', '.join(enabled)}). " + "The two memory systems collide; disable one of them.", + UserWarning, + stacklevel=3, + ) + async def on_started(self, agent_name: str, agent_description: str) -> None: """Deep-copy the caller's agent and sync the converter identity.""" await super().on_started(agent_name, agent_description) From 9e00b6734bd354c07d4709e3861befd2abd5fa54 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 09:55:33 +0300 Subject: [PATCH 10/90] test(agno): register adapter and converter with conformance suite Add AgnoOutputAdapter (reads role/content off agno Message objects) and register agno in the converter and adapter config registries: - converter: filters_own_messages=False, skips_tool_events=True (text-only) - adapter: inject a stand-in agent in the factory; no model/prompt or custom tools, so assert adapter-level state (agent=None, _band_tools_wired=False) Resolves the config-drift failure for the new agno modules. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/framework_configs/adapters.py | 30 ++++++++++++++ tests/framework_configs/converters.py | 25 ++++++++++++ tests/framework_configs/output_adapters.py | 47 ++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 8737a4d75..74754988b 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -264,6 +264,16 @@ def _opencode_factory(**kw: Any) -> Any: return OpencodeAdapter(**kw) +def _agno_factory(**kw: Any) -> Any: + from band.adapters.agno import AgnoAdapter + + # AgnoAdapter takes a developer-built Agno Agent; inject a stand-in so the + # adapter can be constructed without a real model/API key. + if "agent" not in kw: + kw["agent"] = MagicMock() + return AgnoAdapter(**kw) + + def _gemini_factory(**kw: Any) -> Any: from band.adapters.gemini import GeminiAdapter @@ -664,6 +674,25 @@ def _build_opencode_config() -> AdapterConfig: ) +def _build_agno_config() -> AdapterConfig: + return AdapterConfig( + framework_id="agno", + display_name="Agno", + adapter_factory=_agno_factory, + # AgnoAdapter has no model/prompt of its own (the caller's Agno agent + # owns those); assert the adapter-level state instead. + expected_initial_values={ + "agent": None, # the run copy is built in on_started + "_band_tools_wired": False, + }, + # No model/prompt kwargs to customize; nothing to assert here. + custom_kwargs={}, + custom_expected={}, + # AgnoAdapter does not expose Band custom tools (no additional_tools). + has_custom_tools_attr=False, + ) + + def _build_gemini_config() -> AdapterConfig: from band.adapters.gemini import GeminiAdapter @@ -745,6 +774,7 @@ def _build_google_adk_config() -> AdapterConfig: _build_codex_config, _build_letta_config, _build_opencode_config, + _build_agno_config, _build_gemini_config, _build_google_adk_config, ] diff --git a/tests/framework_configs/converters.py b/tests/framework_configs/converters.py index 99b13d8f8..f863c6b1c 100644 --- a/tests/framework_configs/converters.py +++ b/tests/framework_configs/converters.py @@ -112,6 +112,12 @@ def _parlant_factory(**kw: Any) -> Any: return ParlantHistoryConverter(**kw) +def _agno_factory(**kw: Any) -> Any: + from band.converters.agno import AgnoHistoryConverter + + return AgnoHistoryConverter(**kw) + + def _gemini_factory(**kw: Any) -> Any: from band.converters.gemini import GeminiHistoryConverter @@ -233,6 +239,24 @@ def _build_parlant_config() -> ConverterConfig: ) +def _build_agno_config() -> ConverterConfig: + from tests.framework_configs.output_adapters import AgnoOutputAdapter + + return ConverterConfig( + framework_id="agno", + display_name="Agno", + converter_factory=_agno_factory, + empty_result=[], + # Keeps own-agent text as an assistant Message (not filtered). + filters_own_messages=False, + # Text-only converter: tool_call/tool_result events are skipped. + skips_tool_events=True, + empty_sender_behavior=SenderBehavior.CONTENT_AS_IS, + missing_sender_behavior=SenderBehavior.CONTENT_AS_IS, + output_adapter=AgnoOutputAdapter(), + ) + + def _build_gemini_config() -> ConverterConfig: from tests.framework_configs.output_adapters import GeminiOutputAdapter @@ -301,6 +325,7 @@ def _build_google_adk_config() -> ConverterConfig: _build_claude_sdk_config, _build_pydantic_ai_config, _build_parlant_config, + _build_agno_config, _build_gemini_config, _build_google_adk_config, ] diff --git a/tests/framework_configs/output_adapters.py b/tests/framework_configs/output_adapters.py index b17c1fab7..11132108f 100644 --- a/tests/framework_configs/output_adapters.py +++ b/tests/framework_configs/output_adapters.py @@ -18,6 +18,7 @@ "GoogleADKOutputAdapter", "LangChainOutputAdapter", "PydanticAIOutputAdapter", + "AgnoOutputAdapter", "GeminiOutputAdapter", "StringOutputAdapter", "SenderDictListAdapter", @@ -180,6 +181,52 @@ def assert_sender_metadata( ) +class AgnoOutputAdapter: + """Adapter for Agno converter output (list of agno Message objects).""" + + def assert_result_type(self, result: list) -> None: + assert isinstance(result, list), f"Expected list, got {type(result).__name__}" + + def result_length(self, result: list) -> int: + return len(result) + + def get_content(self, result: list, index: int) -> str: + return result[index].content or "" + + def get_role(self, result: list, index: int) -> str: + return result[index].role + + def is_empty(self, result: list) -> bool: + return len(result) == 0 + + def content_contains(self, result: list, substring: str) -> bool: + return any(substring in (msg.content or "") for msg in result) + + def assert_element_type(self, result: list, index: int, expected_role: str) -> None: + from agno.models.message import Message + + msg = result[index] + assert isinstance(msg, Message), ( + f"Expected agno Message, got {type(msg).__name__}" + ) + assert msg.role == expected_role, ( + f"Expected role {expected_role!r}, got {msg.role!r}" + ) + + def assert_sender_metadata( + self, + result: list, + index: int, + sender_name: str, + sender_type: str | None = None, + ) -> None: + raise NotImplementedError( + "AgnoOutputAdapter.assert_sender_metadata() is not supported. " + "Agno messages do not include sender metadata. " + "Ensure has_sender_metadata=False in the ConverterConfig." + ) + + class PydanticAIOutputAdapter: """Adapter for PydanticAI converter output (list of ModelRequest/ModelResponse).""" From f35148ab55a4837d1e0871cda707422e8daccb42 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:02:46 +0300 Subject: [PATCH 11/90] fix(agno): maintain per-room history across turns and restart Band delivers rehydrated platform history only on session bootstrap; later messages arrive with empty history. The adapter previously rebuilt input from the history arg each call, so it dropped all context after the first turn. Accumulate a per-room transcript (self._message_history) seeded from history on bootstrap and appended with each user message and assistant reply, and feed it to Agno on every run. Restart re-seeds from the rehydrated history; on_cleanup drops the room's transcript. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index a9157b6d4..3c57280a2 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent + from agno.models.message import Message from agno.run.agent import RunOutput from agno.tools.function import Function @@ -107,11 +108,16 @@ def __init__( # The caller's agent is the source of configuration. We never mutate it: # on_started builds a deep copy (`self.agent`) that we wire Band tools # into and run. The copy is shared across rooms/messages; Agno keeps - # per-run state in its run context and Band history is passed as input - # on every call, so a single instance is safe to reuse. + # per-run state in its run context, so a single instance is safe to reuse. self._source_agent = agent self.agent: AgnoAgent | None = None + # Per-room running transcript. Band delivers the rehydrated platform + # history only on session bootstrap (including after a restart); later + # messages arrive with empty history, so the adapter accumulates the + # conversation itself and feeds it to Agno on every run. + self._message_history: dict[str, list[Message]] = {} + # Band capability tools (memory/contacts) are wired into the copy once, # on the first message, since they are room-agnostic. self._band_tools_wired = False @@ -179,8 +185,14 @@ async def on_message( # Expose Band memory/contact tools to the agent (once, room-agnostic). self._ensure_band_tools(tools) - # Band history is the source of truth; build the input fresh each call. - messages: list[Message] = list(history) + # Seed the running transcript from the rehydrated platform history on + # bootstrap (or restart); otherwise reuse what we have accumulated. + if is_session_bootstrap: + self._message_history[room_id] = list(history) + elif room_id not in self._message_history: + self._message_history[room_id] = [] + messages = self._message_history[room_id] + if participants_msg: messages.append( Message(role="user", content=f"[System]: {participants_msg}") @@ -218,6 +230,10 @@ async def on_message( logger.debug("Room %s: Agno agent returned empty content", room_id) return + # Persist the reply so the next turn (which arrives with empty history) + # still has the full conversation context. + messages.append(Message(role="assistant", content=text)) + if response.content_type not in ("str", ""): logger.debug( "Room %s: Agno returned %s output; sending JSON-serialized form", @@ -229,6 +245,10 @@ async def on_message( logger.info("Room %s: sending reply (%d chars)", room_id, len(text)) await tools.send_message(text, mentions=mention) + async def on_cleanup(self, room_id: str) -> None: + """Drop the room's accumulated transcript when the agent leaves.""" + self._message_history.pop(room_id, None) + def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: """Wire Band memory/contact tools into the Agno agent once. From 0bfc849e602a9790b1f19d2687254e55eebf93d0 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:06:45 +0300 Subject: [PATCH 12/90] test(agno): add agno to the E2E adapter suite Register a create_agno_adapter factory (Claude model via ANTHROPIC_API_KEY) and add "agno" to the parametrized adapter_entry fixture so the smoke and tool-execution E2E tests cover it alongside the other adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/adapters/conftest.py | 16 ++++++++++++++++ tests/e2e/adapters/test_all_adapters.py | 2 +- tests/e2e/conftest.py | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/adapters/conftest.py b/tests/e2e/adapters/conftest.py index c17f15f76..69a8cbdaf 100644 --- a/tests/e2e/adapters/conftest.py +++ b/tests/e2e/adapters/conftest.py @@ -106,6 +106,21 @@ def create_crewai_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: ) +def create_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: + """Create an Agno adapter with a cheap Claude model.""" + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions="Keep responses short and concise.", + ) + return AgnoAdapter(agno_agent) + + # ============================================================================= # Adapter Registry # ============================================================================= @@ -116,6 +131,7 @@ def create_crewai_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: "pydantic_ai": create_pydantic_ai_adapter, "claude_sdk": create_claude_sdk_adapter, "crewai": create_crewai_adapter, + "agno": create_agno_adapter, } # Note: Parlant is excluded from the default parametrized set because it diff --git a/tests/e2e/adapters/test_all_adapters.py b/tests/e2e/adapters/test_all_adapters.py index 689824d62..82c4ce19b 100644 --- a/tests/e2e/adapters/test_all_adapters.py +++ b/tests/e2e/adapters/test_all_adapters.py @@ -4,7 +4,7 @@ - Start, process a message, and stop against a real platform - Execute platform tools (send_message) -Adapters tested: langgraph, anthropic, pydantic_ai, claude_sdk, crewai. +Adapters tested: langgraph, anthropic, pydantic_ai, claude_sdk, crewai, agno. Parlant is excluded (requires separate server setup, see test_parlant.py). Run with: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d6b2e8fdf..fe98419f3 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -425,6 +425,7 @@ async def ws_client( "pydantic_ai", "claude_sdk", "crewai", + "agno", ] ) def adapter_entry( From f37945d3b19d3262e271ebff6b775be638991fab Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:21:29 +0300 Subject: [PATCH 13/90] fix(agno): pass mentions as list[str] handles, not deprecated dicts Reply with mentions=[msg.sender_id]; the SDK resolves handles/names/IDs from the string form. Avoids the list[dict] mentions deprecation warning. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 3c57280a2..7788e7a4e 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -241,9 +241,9 @@ async def on_message( response.content_type, ) - mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] logger.info("Room %s: sending reply (%d chars)", room_id, len(text)) - await tools.send_message(text, mentions=mention) + # mentions accepts handles/names/IDs as strings; the SDK resolves them. + await tools.send_message(text, mentions=[msg.sender_id]) async def on_cleanup(self, room_id: str) -> None: """Drop the room's accumulated transcript when the agent leaves.""" From 5d96fc44523aad2bb66fff31ac4788f8f1611069 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:21:29 +0300 Subject: [PATCH 14/90] test(e2e): update retired default anthropic model claude-3-haiku-20240307 is retired (Anthropic 404). Default e2e_anthropic_model to claude-haiku-4-5-20251001 so the Anthropic-family adapter E2E tests (anthropic, claude_sdk, agno) authenticate against a current model. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index fe98419f3..d1e18c26a 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -97,7 +97,7 @@ class E2ESettings(BaseTestSettings): # E2E-specific settings (override via environment variables) e2e_llm_model: str = "gpt-5.4-mini" - e2e_anthropic_model: str = "claude-3-haiku-20240307" + e2e_anthropic_model: str = "claude-haiku-4-5-20251001" e2e_timeout: int = 30 e2e_tests_enabled: bool = False From b6b42cb21d0c0612a7e16eaf0b8af1e61d60b136 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:30:09 +0300 Subject: [PATCH 15/90] feat(agno): convert tool events in rehydrated history The converter now carries tool_call/tool_result events through rehydration: tool calls become an assistant message with batched OpenAI-style tool_calls, and results become tool-role messages paired by tool_call_id. This is Agno's own history shape, so prior tool turns round-trip back through arun(). Dispatch uses match/case with a focused builder per event type. Flips the conformance skips_tool_events flag and extends AgnoOutputAdapter to inspect tool-call names/args. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/README.md | 7 +- src/band/converters/agno.py | 109 +++++++++++++++++---- tests/framework_configs/converters.py | 4 +- tests/framework_configs/output_adapters.py | 13 ++- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/examples/agno/README.md b/examples/agno/README.md index 79db72d35..9f3e0758c 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -10,8 +10,11 @@ instructions, and — in a later iteration — tools), then bridge it to Band wi `AgnoAdapter`. The adapter converts Band room history into Agno messages, runs your agent, and replies with its text output. -> **Note:** This is an early, text-only integration. Band platform tools -> (`band_send_message`, etc.) are not wired into the Agno agent yet. +> **Note:** Band's memory/contact tools are exposed to the agent when the +> matching capabilities are enabled, and tool executions are reported to (and +> rehydrated from) the room. The chat/participant tools (`band_send_message`, +> etc.) are not exposed — the adapter sends the agent's reply to the room +> directly. ## Prerequisites diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index cc72174f8..9e4a0bb6c 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -2,11 +2,14 @@ from __future__ import annotations +import json import logging from typing import TYPE_CHECKING, Any from band.core.protocols import HistoryConverter +from ._tool_parsing import parse_tool_call, parse_tool_result + if TYPE_CHECKING: from agno.models.message import Message @@ -21,12 +24,15 @@ class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): """ Convert platform history to Agno message format. - Output (text-only skeleton): + Output: - this agent's text messages -> Message(role="assistant", content=...) - everyone else's text messages -> Message(role="user", content="[name]: ...") + - tool_call events -> Message(role="assistant", tool_calls=[{id, type, function}]) + (consecutive calls are batched into one assistant message) + - tool_result events -> Message(role="tool", tool_call_id=..., content=output) - NOTE: tool_call / tool_result / thought events are skipped for now. Tool-event - conversion lands together with Band platform-tool wiring in a follow-up. + This is Agno's own history shape, so rehydrated tool turns round-trip back + through ``Agent.arun(input=...)`` for whichever model the agent uses. """ def __init__(self, agent_name: str = ""): @@ -36,25 +42,88 @@ def set_agent_name(self, name: str) -> None: self._agent_name = name def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: - """Convert platform history to Agno messages.""" - from agno.models.message import Message - - messages: list[Message] = [] + """Dispatch each platform event to its Agno-message builder.""" + messages: AgnoMessages = [] + # Buffer consecutive tool calls so they land in a single assistant + # message (matching how Agno emits parallel tool calls). + pending_calls: list[dict[str, Any]] = [] for hist in raw: - message_type = hist.get("message_type", "text") - if message_type != "text": - # Skip tool_call / tool_result / thought events for now. - continue + match hist.get("message_type", "text"): + case "tool_call": + call = self._tool_call_dict(hist.get("content", "")) + if call is not None: + pending_calls.append(call) + case "tool_result": + # The assistant tool_calls message must precede its results. + self._flush_tool_calls(messages, pending_calls) + self._append_tool_result(messages, hist.get("content", "")) + case "text": + self._flush_tool_calls(messages, pending_calls) + messages.append(self._text_message(hist)) + case _: + continue # skip thought and other non-text, non-tool events + + self._flush_tool_calls(messages, pending_calls) + return messages + + @staticmethod + def _tool_call_dict(content: str) -> dict[str, Any] | None: + """Shape a tool_call event into an Agno (OpenAI-style) tool call.""" + parsed = parse_tool_call(content) + if parsed is None: + return None + return { + "id": parsed.tool_call_id, + "type": "function", + "function": { + "name": parsed.name, + "arguments": json.dumps(parsed.args), + }, + } + + @staticmethod + def _flush_tool_calls( + messages: AgnoMessages, pending_calls: list[dict[str, Any]] + ) -> None: + """Emit buffered tool calls as one assistant message, then clear them.""" + if not pending_calls: + return + from agno.models.message import Message - content = hist.get("content", "") - role = hist.get("role", "user") - sender_name = hist.get("sender_name", "") + messages.append( + Message(role="assistant", content=None, tool_calls=list(pending_calls)) + ) + pending_calls.clear() + + @staticmethod + def _append_tool_result(messages: AgnoMessages, content: str) -> None: + """Append a tool_result event as a tool-role message.""" + parsed = parse_tool_result(content) + if parsed is None: + return + from agno.models.message import Message - if role == "assistant" and sender_name == self._agent_name: - messages.append(Message(role="assistant", content=content)) - else: - formatted = f"[{sender_name}]: {content}" if sender_name else content - messages.append(Message(role="user", content=formatted)) + messages.append( + Message( + role="tool", + tool_call_id=parsed.tool_call_id, + tool_name=parsed.name, + content=parsed.output, + tool_call_error=parsed.is_error, + ) + ) + + def _text_message(self, hist: dict[str, Any]) -> Message: + """Map a text event to a user/assistant message with sender attribution.""" + from agno.models.message import Message - return messages + content = hist.get("content", "") + if hist.get("role") == "assistant" and hist.get("sender_name") == ( + self._agent_name + ): + return Message(role="assistant", content=content) + + sender_name = hist.get("sender_name", "") + formatted = f"[{sender_name}]: {content}" if sender_name else content + return Message(role="user", content=formatted) diff --git a/tests/framework_configs/converters.py b/tests/framework_configs/converters.py index f863c6b1c..6e92b3367 100644 --- a/tests/framework_configs/converters.py +++ b/tests/framework_configs/converters.py @@ -249,8 +249,8 @@ def _build_agno_config() -> ConverterConfig: empty_result=[], # Keeps own-agent text as an assistant Message (not filtered). filters_own_messages=False, - # Text-only converter: tool_call/tool_result events are skipped. - skips_tool_events=True, + # Converts tool_call -> assistant tool_calls, tool_result -> tool message. + skips_tool_events=False, empty_sender_behavior=SenderBehavior.CONTENT_AS_IS, missing_sender_behavior=SenderBehavior.CONTENT_AS_IS, output_adapter=AgnoOutputAdapter(), diff --git a/tests/framework_configs/output_adapters.py b/tests/framework_configs/output_adapters.py index 11132108f..9e59792be 100644 --- a/tests/framework_configs/output_adapters.py +++ b/tests/framework_configs/output_adapters.py @@ -200,7 +200,18 @@ def is_empty(self, result: list) -> bool: return len(result) == 0 def content_contains(self, result: list, substring: str) -> bool: - return any(substring in (msg.content or "") for msg in result) + for msg in result: + if msg.content and substring in str(msg.content): + return True + if getattr(msg, "tool_name", None) and substring in msg.tool_name: + return True + for tc in getattr(msg, "tool_calls", None) or []: + fn = tc.get("function", {}) + if substring in fn.get("name", "") or substring in str( + fn.get("arguments", "") + ): + return True + return False def assert_element_type(self, result: list, index: int, expected_role: str) -> None: from agno.models.message import Message From 37f1dd1724a412b09119e4ad1f3d4ad0684e4c1d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:33:45 +0300 Subject: [PATCH 16/90] refactor(agno): resolve Agno Message via a cached_property Replace the per-method `from agno.models.message import Message` statements with a single `_message_class` cached_property on the converter. Keeps the module importable without agno installed, raises one clear ImportError on use, and lets call sites read as `self._message_class(role=..., ...)`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/converters/agno.py | 40 +++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 9e4a0bb6c..d5b6445b7 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -4,6 +4,7 @@ import json import logging +from functools import cached_property from typing import TYPE_CHECKING, Any from band.core.protocols import HistoryConverter @@ -16,7 +17,7 @@ logger = logging.getLogger(__name__) # Forward-referenced so this module imports without agno installed; the real -# Message type is imported lazily inside convert(). +# Message type is resolved lazily via the converter's _message_class property. AgnoMessages = list["Message"] @@ -41,6 +42,21 @@ def __init__(self, agent_name: str = ""): def set_agent_name(self, name: str) -> None: self._agent_name = name + @cached_property + def _message_class(self) -> type[Message]: + """Agno's ``Message`` class, imported lazily and once per converter. + + Keeps this module importable without agno installed; raises a clear + error only when a message is actually built. + """ + try: + from agno.models.message import Message + except ImportError as e: + raise ImportError( + "Agno dependencies not installed. Install with: uv add band-sdk[agno]" + ) from e + return Message + def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: """Dispatch each platform event to its Agno-message builder.""" messages: AgnoMessages = [] @@ -82,30 +98,26 @@ def _tool_call_dict(content: str) -> dict[str, Any] | None: }, } - @staticmethod def _flush_tool_calls( - messages: AgnoMessages, pending_calls: list[dict[str, Any]] + self, messages: AgnoMessages, pending_calls: list[dict[str, Any]] ) -> None: """Emit buffered tool calls as one assistant message, then clear them.""" if not pending_calls: return - from agno.models.message import Message - messages.append( - Message(role="assistant", content=None, tool_calls=list(pending_calls)) + self._message_class( + role="assistant", content=None, tool_calls=list(pending_calls) + ) ) pending_calls.clear() - @staticmethod - def _append_tool_result(messages: AgnoMessages, content: str) -> None: + def _append_tool_result(self, messages: AgnoMessages, content: str) -> None: """Append a tool_result event as a tool-role message.""" parsed = parse_tool_result(content) if parsed is None: return - from agno.models.message import Message - messages.append( - Message( + self._message_class( role="tool", tool_call_id=parsed.tool_call_id, tool_name=parsed.name, @@ -116,14 +128,12 @@ def _append_tool_result(messages: AgnoMessages, content: str) -> None: def _text_message(self, hist: dict[str, Any]) -> Message: """Map a text event to a user/assistant message with sender attribution.""" - from agno.models.message import Message - content = hist.get("content", "") if hist.get("role") == "assistant" and hist.get("sender_name") == ( self._agent_name ): - return Message(role="assistant", content=content) + return self._message_class(role="assistant", content=content) sender_name = hist.get("sender_name", "") formatted = f"[{sender_name}]: {content}" if sender_name else content - return Message(role="user", content=formatted) + return self._message_class(role="user", content=formatted) From d58b686f25432f20cdb8bf98f5b209a5e52ac62d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:51:01 +0300 Subject: [PATCH 17/90] chore(agno): add concise debug logging Converter logs event->message conversion counts. Adapter logs the resolved emit/capability features at startup and the bootstrap history-seed size, so rehydration and feature config are visible at DEBUG. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 10 ++++++++++ src/band/converters/agno.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 7788e7a4e..ce04f9c9b 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -161,6 +161,11 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: self.history_converter.set_agent_name(agent_name) logger.info("Agno adapter started for agent: %s", agent_name) + logger.debug( + "Agno adapter features: emit=%s capabilities=%s", + sorted(e.value for e in self.features.emit), + sorted(c.value for c in self.features.capabilities), + ) async def on_message( self, @@ -189,6 +194,11 @@ async def on_message( # bootstrap (or restart); otherwise reuse what we have accumulated. if is_session_bootstrap: self._message_history[room_id] = list(history) + logger.debug( + "Room %s: bootstrap seeded %d message(s) from rehydrated history", + room_id, + len(history), + ) elif room_id not in self._message_history: self._message_history[room_id] = [] messages = self._message_history[room_id] diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index d5b6445b7..78e55c793 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -81,6 +81,11 @@ def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: continue # skip thought and other non-text, non-tool events self._flush_tool_calls(messages, pending_calls) + logger.debug( + "Converted %d platform event(s) into %d Agno message(s)", + len(raw), + len(messages), + ) return messages @staticmethod From 26a3d6926ab7a5dc948130077cacc22845afb332 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 10:55:05 +0300 Subject: [PATCH 18/90] chore(agno): include room id, message id, and mentions in logs Prefix every on_message/tool-report log with "Room msg " and log the sender id on intake and the mentions on reply, so a message can be traced end-to-end across the adapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 58 ++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index ce04f9c9b..2e266e825 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -185,7 +185,13 @@ async def on_message( raise RuntimeError("Agno agent not initialized; on_started was not called") sender = msg.sender_name or msg.sender_type - logger.info("Room %s: handling message from %s", room_id, sender) + logger.info( + "Room %s msg %s: handling from %s (sender=%s)", + room_id, + msg.id, + sender, + msg.sender_id, + ) # Expose Band memory/contact tools to the agent (once, room-agnostic). self._ensure_band_tools(tools) @@ -195,8 +201,9 @@ async def on_message( if is_session_bootstrap: self._message_history[room_id] = list(history) logger.debug( - "Room %s: bootstrap seeded %d message(s) from rehydrated history", + "Room %s msg %s: bootstrap seeded %d message(s) from rehydrated history", room_id, + msg.id, len(history), ) elif room_id not in self._message_history: @@ -212,32 +219,41 @@ async def on_message( messages.append(Message(role="user", content=msg.format_for_llm())) logger.debug( - "Room %s: running Agno agent (%d input messages)", room_id, len(messages) + "Room %s msg %s: running Agno agent (%d input messages)", + room_id, + msg.id, + len(messages), ) # Bind the room's tools so wired Band tools execute against this room. token = _current_tools.set(tools) try: response = await self.agent.arun(input=messages) except Exception as e: - logger.exception("Error running Agno agent in room %s: %s", room_id, e) + logger.exception( + "Room %s msg %s: error running Agno agent: %s", room_id, msg.id, e + ) raise finally: _current_tools.reset(token) if response is None: - logger.debug("Room %s: Agno agent returned no response", room_id) + logger.debug( + "Room %s msg %s: Agno agent returned no response", room_id, msg.id + ) return # Report the agent's own tool executions (happened during the run, so # before the final reply) when execution reporting is enabled. if Emit.EXECUTION in self.features.emit: - await self._report_tool_executions(response, tools, room_id) + await self._report_tool_executions(response, tools, room_id, msg.id) # get_content_as_string() handles str, structured (BaseModel -> JSON), # and dict/list output uniformly. text = response.get_content_as_string().strip() if not text: - logger.debug("Room %s: Agno agent returned empty content", room_id) + logger.debug( + "Room %s msg %s: Agno agent returned empty content", room_id, msg.id + ) return # Persist the reply so the next turn (which arrives with empty history) @@ -246,14 +262,22 @@ async def on_message( if response.content_type not in ("str", ""): logger.debug( - "Room %s: Agno returned %s output; sending JSON-serialized form", + "Room %s msg %s: Agno returned %s output; sending JSON-serialized form", room_id, + msg.id, response.content_type, ) - logger.info("Room %s: sending reply (%d chars)", room_id, len(text)) # mentions accepts handles/names/IDs as strings; the SDK resolves them. - await tools.send_message(text, mentions=[msg.sender_id]) + mentions = [msg.sender_id] + logger.info( + "Room %s msg %s: sending reply (%d chars), mentions=%s", + room_id, + msg.id, + len(text), + mentions, + ) + await tools.send_message(text, mentions=mentions) async def on_cleanup(self, room_id: str) -> None: """Drop the room's accumulated transcript when the agent leaves.""" @@ -330,13 +354,19 @@ async def _report_tool_executions( response: RunOutput, tools: AgentToolsProtocol, room_id: str, + msg_id: str, ) -> None: """Emit tool_call/tool_result events for the agent's tool executions.""" executions = list(getattr(response, "tools", None) or []) if not executions: return - logger.info("Room %s: reporting %d tool execution(s)", room_id, len(executions)) + logger.info( + "Room %s msg %s: reporting %d tool execution(s)", + room_id, + msg_id, + len(executions), + ) for te in executions: tool_call_id = getattr(te, "tool_call_id", None) or "" tool_name = getattr(te, "tool_name", None) or "" @@ -344,8 +374,9 @@ async def _report_tool_executions( is_error = bool(getattr(te, "tool_call_error", False)) result = str(getattr(te, "result", "") or "") logger.debug( - "Room %s: tool %s(%s) -> %s%s", + "Room %s msg %s: tool %s(%s) -> %s%s", room_id, + msg_id, tool_name, tool_args, result[:200], @@ -375,8 +406,9 @@ async def _report_tool_executions( ) except Exception as e: logger.warning( - "Room %s: failed to report tool execution %s: %s", + "Room %s msg %s: failed to report tool execution %s: %s", room_id, + msg_id, tool_name, e, ) From 74d842e71834983c2773708e7545a638cd3e5b0e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 11:17:16 +0300 Subject: [PATCH 19/90] docs(agno): add Tom & Jerry two-agent example 03_tom_and_jerry.py spins up two Agno-backed Band agents (Tom and Jerry) with distinct personalities in one process via asyncio.gather, reusing the shared character prompts. They reply in character when mentioned in a shared room (the Agno adapter is reply-only, so they don't auto-invite like the CrewAI version). Adds tom/jery entries to agent_config.yaml.example and the README. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_config.yaml.example | 11 +++- examples/agno/03_tom_and_jerry.py | 94 +++++++++++++++++++++++++++++++ examples/agno/README.md | 1 + 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 examples/agno/03_tom_and_jerry.py diff --git a/agent_config.yaml.example b/agent_config.yaml.example index b55f47a15..1d3f89086 100644 --- a/agent_config.yaml.example +++ b/agent_config.yaml.example @@ -171,11 +171,20 @@ gemini_agent: # Agno Examples # ============================================================================= -# 01_basic_agent.py +# 01_basic_agent.py, 02_tool_reporting.py agno_agent: agent_id: "" api_key: "" +# 03_tom_and_jerry.py - two character agents in one process +tom: + agent_id: "" + api_key: "" + +jery: + agent_id: "" + api_key: "" + # Google ADK Examples # ============================================================================= diff --git a/examples/agno/03_tom_and_jerry.py b/examples/agno/03_tom_and_jerry.py new file mode 100644 index 000000000..c56def21c --- /dev/null +++ b/examples/agno/03_tom_and_jerry.py @@ -0,0 +1,94 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Tom and Jerry — two Agno character agents in one process. + +Spins up both Tom (the cat) and Jerry (the mouse) as separate Band agents, +each backed by its own Agno agent with a distinct personality, and runs them +concurrently with asyncio.gather. + +Add both agents to the same Band room and mention them: they reply in character +and bicker back and forth. (Unlike the CrewAI Tom/Jerry example, the Agno +adapter is reply-only — it does not expose chat/participant tools, so the +agents respond when mentioned rather than autonomously inviting each other.) + +Requires: + - agent_config.yaml with `tom` and `jery` entries (agent_id + api_key) + - BAND_WS_URL and BAND_REST_URL environment variables + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Run with (from repo root): + uv run examples/agno/03_tom_and_jerry.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys + +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from prompts.characters import generate_jerry_prompt, generate_tom_prompt + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def load_environment() -> tuple[str, str]: + """Load env vars, validate credentials, and return (ws_url, rest_url).""" + load_dotenv() + + if not os.environ.get("ANTHROPIC_API_KEY"): + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + ws_url = os.environ.get("BAND_WS_URL") + rest_url = os.environ.get("BAND_REST_URL") + if not ws_url: + raise ValueError("BAND_WS_URL environment variable is required") + if not rest_url: + raise ValueError("BAND_REST_URL environment variable is required") + return ws_url, rest_url + + +def build_agent(config_key: str, instructions: str, ws_url: str, rest_url: str) -> Agent: + """Build a Band agent backed by an in-character Agno agent.""" + agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions=instructions, + ) + return Agent.from_config( + config_key, + adapter=AgnoAdapter(agno_agent), + ws_url=ws_url, + rest_url=rest_url, + ) + + +async def main() -> None: + ws_url, rest_url = load_environment() + + tom = build_agent("tom", generate_tom_prompt("Tom", "Jerry"), ws_url, rest_url) + jerry = build_agent("jery", generate_jerry_prompt("Jerry", "Tom"), ws_url, rest_url) + + logger.info("Starting Tom and Jerry...") + await asyncio.gather(tom.run(), jerry.run()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agno/README.md b/examples/agno/README.md index 9f3e0758c..16fbab965 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -54,6 +54,7 @@ await agent.run() |------|-------------| | `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | | `02_tool_reporting.py` | **Tool-execution reporting** - An Agno agent with its own tools; `AdapterFeatures(emit={Emit.EXECUTION})` posts tool_call/tool_result events to the room. | +| `03_tom_and_jerry.py` | **Two agents in one process** - Tom and Jerry, each its own Agno-backed Band agent with a distinct personality, run concurrently with `asyncio.gather`. | --- From f5505bbaf7926def91356e4db72fb8ea695446eb Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 11:41:31 +0300 Subject: [PATCH 20/90] tomandjerry --- examples/agno/03_tom_and_jerry.py | 12 +++-- examples/agno/README.md | 10 ++-- src/band/adapters/agno.py | 89 ++++++++++++++++--------------- 3 files changed, 58 insertions(+), 53 deletions(-) diff --git a/examples/agno/03_tom_and_jerry.py b/examples/agno/03_tom_and_jerry.py index c56def21c..4867d1083 100644 --- a/examples/agno/03_tom_and_jerry.py +++ b/examples/agno/03_tom_and_jerry.py @@ -13,9 +13,8 @@ concurrently with asyncio.gather. Add both agents to the same Band room and mention them: they reply in character -and bicker back and forth. (Unlike the CrewAI Tom/Jerry example, the Agno -adapter is reply-only — it does not expose chat/participant tools, so the -agents respond when mentioned rather than autonomously inviting each other.) +and bicker back and forth. Each agent has the Band toolset, so they can also +look up and invite each other, then keep the chase going. Requires: - agent_config.yaml with `tom` and `jery` entries (agent_id + api_key) @@ -39,6 +38,7 @@ from band import Agent from band.adapters import AgnoAdapter +from band.core.types import AdapterFeatures, Emit sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -74,7 +74,11 @@ def build_agent(config_key: str, instructions: str, ws_url: str, rest_url: str) ) return Agent.from_config( config_key, - adapter=AgnoAdapter(agno_agent), + # emit=EXECUTION posts tool_call/tool_result events so the agents' + # platform actions (lookup, invite, send) are visible in the room. + adapter=AgnoAdapter( + agno_agent, features=AdapterFeatures(emit={Emit.EXECUTION}) + ), ws_url=ws_url, rest_url=rest_url, ) diff --git a/examples/agno/README.md b/examples/agno/README.md index 16fbab965..61b2f36c9 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -10,11 +10,11 @@ instructions, and — in a later iteration — tools), then bridge it to Band wi `AgnoAdapter`. The adapter converts Band room history into Agno messages, runs your agent, and replies with its text output. -> **Note:** Band's memory/contact tools are exposed to the agent when the -> matching capabilities are enabled, and tool executions are reported to (and -> rehydrated from) the room. The chat/participant tools (`band_send_message`, -> etc.) are not exposed — the adapter sends the agent's reply to the room -> directly. +> **Note:** The Band toolset is exposed to the agent — chat and participant +> tools always, plus memory/contact tools when the matching capabilities are +> enabled. If the agent doesn't post via `band_send_message`, the adapter sends +> its final text as a fallback, so simple agents reply without extra prompting. +> Tool executions are reported to (and rehydrated from) the room. ## Prerequisites diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 2e266e825..5abf8ef1e 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,10 +8,9 @@ Unlike adapters that run an explicit tool-calling loop, Agno owns its own agent loop internally: ``Agent.arun(input=...)`` accepts a list of Agno messages and -returns a run output whose ``.content`` is the final text. When the matching -capabilities are enabled, Band's memory/contact tools are wired into the Agno -agent so the model can call them, and the agent's tool executions are reported -to the room when ``Emit.EXECUTION`` is enabled. +returns a run output whose ``.content`` is the final text. The Band toolset is +exposed to the agent so it can send messages and act on the platform itself; +tool executions are reported to the room when ``Emit.EXECUTION`` is enabled. """ from __future__ import annotations @@ -68,10 +67,16 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): Takes a developer-built Agno ``Agent`` and bridges it to Band. Stateless per room: Band history is the source of truth and is passed as input on every - message. Band's memory/contact tools are exposed to the agent when the - matching capabilities are enabled, and the agent's tool executions are - reported to the room as tool_call/tool_result events when ``Emit.EXECUTION`` - is enabled. + message. + + The Band toolset is exposed to the agent — chat and participant tools always, + plus memory/contact tools when the matching capabilities are enabled — so it + can send messages, invite peers, and act on the platform itself. If the agent + does not post via ``band_send_message``, its final text is sent as a fallback, + so simple agents still reply without any Band-specific prompting. + + Tool executions are reported to the room as tool_call/tool_result events when + ``Emit.EXECUTION`` is enabled. Example: from agno.agent import Agent as AgnoAgent @@ -247,26 +252,29 @@ async def on_message( if Emit.EXECUTION in self.features.emit: await self._report_tool_executions(response, tools, room_id, msg.id) - # get_content_as_string() handles str, structured (BaseModel -> JSON), - # and dict/list output uniformly. - text = response.get_content_as_string().strip() - if not text: + # Persist the agent's full turn (tool calls/results + reply) so the next + # message has continuity; Agno's run message list is the source of truth. + if response.messages: + self._message_history[room_id] = [ + m for m in response.messages if m.role != "system" + ] + + # The agent may post via band_send_message itself. If it did, we are + # done; otherwise fall back to sending its final text so every agent + # replies regardless of whether it used the tool. + if any( + getattr(te, "tool_name", None) == "band_send_message" + for te in (getattr(response, "tools", None) or []) + ): logger.debug( - "Room %s msg %s: Agno agent returned empty content", room_id, msg.id + "Room %s msg %s: agent replied via band_send_message", room_id, msg.id ) return - # Persist the reply so the next turn (which arrives with empty history) - # still has the full conversation context. - messages.append(Message(role="assistant", content=text)) - - if response.content_type not in ("str", ""): - logger.debug( - "Room %s msg %s: Agno returned %s output; sending JSON-serialized form", - room_id, - msg.id, - response.content_type, - ) + text = response.get_content_as_string().strip() + if not text: + logger.debug("Room %s msg %s: agent produced no reply", room_id, msg.id) + return # mentions accepts handles/names/IDs as strings; the SDK resolves them. mentions = [msg.sender_id] @@ -284,7 +292,7 @@ async def on_cleanup(self, room_id: str) -> None: self._message_history.pop(room_id, None) def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: - """Wire Band memory/contact tools into the Agno agent once. + """Wire the in-scope Band tools into the Agno agent once. These tools are room-agnostic (the active room is supplied via the ``_current_tools`` ContextVar at call time), so they are added to the @@ -304,7 +312,7 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: logger.warning("Could not wire Band tool %s: %s", fn.name, e) if wired: logger.info( - "Wired %d Band capability tool(s) into Agno agent: %s", + "Wired %d Band tool(s) into Agno agent: %s", len(wired), ", ".join(wired), ) @@ -312,30 +320,23 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: self._band_tools_wired = True def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: - """Convert the capability-gated Band tool schemas into Agno Functions.""" + """Convert the in-scope Band tool schemas into Agno Functions. + + Chat/participant tools are always exposed; memory/contact tools are added + when the matching capabilities are enabled. + """ from agno.tools.function import Function - include_memory = Capability.MEMORY in self.features.capabilities - include_contacts = Capability.CONTACTS in self.features.capabilities - if not (include_memory or include_contacts): - return [] - - # The base tools (send_message, participants, ...) are driven by the - # adapter itself; expose only the capability-gated memory/contact tools. - base_names = { - schema["function"]["name"] - for schema in tools.get_openai_tool_schemas( - include_memory=False, include_contacts=False - ) - } + schemas = tools.get_openai_tool_schemas( + include_memory=Capability.MEMORY in self.features.capabilities, + include_contacts=Capability.CONTACTS in self.features.capabilities, + ) band_tools: list[Function] = [] - for schema in tools.get_openai_tool_schemas( - include_memory=include_memory, include_contacts=include_contacts - ): + for schema in schemas: fn = schema.get("function", {}) name = fn.get("name") - if not name or name in base_names: + if not name: continue band_tools.append( Function( From c3868f9e9d6ad395b4a3af5a29b74c1dd039f75a Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:00:23 +0300 Subject: [PATCH 21/90] thoughts --- src/band/adapters/agno.py | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 5abf8ef1e..ff1b22334 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -91,8 +91,10 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): await agent.run() """ - # Can report the Agno agent's own tool executions to the room. - SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION}) + # Can report the agent's tool executions and reasoning to the room. + SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset( + {Emit.EXECUTION, Emit.THOUGHTS} + ) # Can expose Band memory/contact tools to the Agno agent. SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( {Capability.MEMORY, Capability.CONTACTS} @@ -247,6 +249,10 @@ async def on_message( ) return + # Surface the agent's reasoning (if any) before its actions/reply. + if Emit.THOUGHTS in self.features.emit: + await self._report_thoughts(response, tools, room_id, msg.id) + # Report the agent's own tool executions (happened during the run, so # before the final reply) when execution reporting is enabled. if Emit.EXECUTION in self.features.emit: @@ -350,6 +356,37 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: ) return band_tools + async def _report_thoughts( + self, + response: RunOutput, + tools: AgentToolsProtocol, + room_id: str, + msg_id: str, + ) -> None: + """Post the agent's reasoning content as a thought event. + + Only produces output when the developer's Agno agent has reasoning + enabled (e.g. ``reasoning=True`` or a reasoning model); otherwise + ``reasoning_content`` is empty and nothing is posted. + """ + reasoning = getattr(response, "reasoning_content", None) + text = (reasoning or "").strip() if isinstance(reasoning, str) else "" + if not text: + return + + logger.info( + "Room %s msg %s: reporting reasoning as thought (%d chars)", + room_id, + msg_id, + len(text), + ) + try: + await tools.send_event(content=text, message_type="thought") + except Exception as e: + logger.warning( + "Room %s msg %s: failed to report thought: %s", room_id, msg_id, e + ) + async def _report_tool_executions( self, response: RunOutput, @@ -357,8 +394,17 @@ async def _report_tool_executions( room_id: str, msg_id: str, ) -> None: - """Emit tool_call/tool_result events for the agent's tool executions.""" - executions = list(getattr(response, "tools", None) or []) + """Emit tool_call/tool_result events for the agent's tool executions. + + Skips band_send_message/band_send_event: their effect is already a + visible room message/event, so reporting them would double-record the + reply (and duplicate it on rehydration). + """ + executions = [ + te + for te in (getattr(response, "tools", None) or []) + if (getattr(te, "tool_name", None) or "") not in _SELF_REPORTING_TOOLS + ] if not executions: return From 95462c200073e0a7ec720c4290e403579bd26dd5 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:03:45 +0300 Subject: [PATCH 22/90] fix(agno): define _SELF_REPORTING_TOOLS for execution reporting Skip band_send_message/band_send_event in _report_tool_executions: their effect is already a visible room message/event, so reporting them would double-record the reply (and duplicate it on rehydration). Adds the constant the filter referenced. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index ff1b22334..6259d8d89 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -39,6 +39,10 @@ logger = logging.getLogger(__name__) +# Tools whose effect is already a visible room message/event, so their +# execution must not be re-reported as tool_call/tool_result events. +_SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) + # The Band tools handle for the room being processed. Wired Band tools read it # at call time so a single shared Agno agent can serve concurrent rooms — each # on_message coroutine sets its own value (ContextVars are task-isolated). From d8e40a183142df8d95e36f1232aaf9b14962b4cc Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:12:36 +0300 Subject: [PATCH 23/90] refactor(agno): decompose on_message into focused helpers Split the long on_message into a short orchestrator plus single-concern helpers: _build_run_input (assemble transcript), _run_agent (run + ContextVar bind + error handling), _persist_turn (continuity), and _send_reply (fallback reply). Extract _emit_execution from _report_tool_executions, tighten _make_band_entrypoint's return type, and make room_id/msg_id keyword-only on the reporting/run helpers. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 210 +++++++++++++++++++++++++------------- 1 file changed, 137 insertions(+), 73 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 6259d8d89..208739832 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -18,6 +18,7 @@ import json import logging import warnings +from collections.abc import Awaitable, Callable from contextvars import ContextVar from typing import TYPE_CHECKING, Any, ClassVar @@ -51,7 +52,7 @@ ) -def _make_band_entrypoint(tool_name: str) -> Any: +def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: """Build an async Agno tool entrypoint that runs a Band platform tool.""" async def _entrypoint(**kwargs: Any) -> str: @@ -189,26 +190,65 @@ async def on_message( is_session_bootstrap: bool, room_id: str, ) -> None: - """Run the developer's Agno agent on the history and reply with text.""" - from agno.models.message import Message - + """Run the developer's Agno agent and ensure a reply is sent.""" if self.agent is None: raise RuntimeError("Agno agent not initialized; on_started was not called") - sender = msg.sender_name or msg.sender_type logger.info( "Room %s msg %s: handling from %s (sender=%s)", room_id, msg.id, - sender, + msg.sender_name or msg.sender_type, msg.sender_id, ) - # Expose Band memory/contact tools to the agent (once, room-agnostic). self._ensure_band_tools(tools) + messages = self._build_run_input( + msg, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) + response = await self._run_agent( + messages, tools, room_id=room_id, msg_id=msg.id + ) + if response is None: + return + + if Emit.THOUGHTS in self.features.emit: + await self._report_thoughts(response, tools, room_id=room_id, msg_id=msg.id) + if Emit.EXECUTION in self.features.emit: + await self._report_tool_executions( + response, tools, room_id=room_id, msg_id=msg.id + ) + + self._persist_turn(room_id, response) + await self._send_reply(msg, tools, response, room_id=room_id) + + async def on_cleanup(self, room_id: str) -> None: + """Drop the room's accumulated transcript when the agent leaves.""" + self._message_history.pop(room_id, None) + + def _build_run_input( + self, + msg: PlatformMessage, + history: AgnoMessages, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> list[Message]: + """Seed the per-room transcript and append the new system/user messages. + + Band delivers the rehydrated platform history only on bootstrap (incl. + after a restart); later messages arrive empty, so the adapter keeps the + running transcript itself. + """ + from agno.models.message import Message - # Seed the running transcript from the rehydrated platform history on - # bootstrap (or restart); otherwise reuse what we have accumulated. if is_session_bootstrap: self._message_history[room_id] = list(history) logger.debug( @@ -219,8 +259,8 @@ async def on_message( ) elif room_id not in self._message_history: self._message_history[room_id] = [] - messages = self._message_history[room_id] + messages = self._message_history[room_id] if participants_msg: messages.append( Message(role="user", content=f"[System]: {participants_msg}") @@ -228,20 +268,33 @@ async def on_message( if contacts_msg: messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) messages.append(Message(role="user", content=msg.format_for_llm())) + return messages + + async def _run_agent( + self, + messages: list[Message], + tools: AgentToolsProtocol, + *, + room_id: str, + msg_id: str, + ) -> RunOutput | None: + """Run the Agno agent with the room's tools bound for this call.""" + agent = self.agent + assert agent is not None # on_message guarantees the agent is initialized logger.debug( "Room %s msg %s: running Agno agent (%d input messages)", room_id, - msg.id, + msg_id, len(messages), ) # Bind the room's tools so wired Band tools execute against this room. token = _current_tools.set(tools) try: - response = await self.agent.arun(input=messages) + response = await agent.arun(input=messages) except Exception as e: logger.exception( - "Room %s msg %s: error running Agno agent: %s", room_id, msg.id, e + "Room %s msg %s: error running Agno agent: %s", room_id, msg_id, e ) raise finally: @@ -249,29 +302,34 @@ async def on_message( if response is None: logger.debug( - "Room %s msg %s: Agno agent returned no response", room_id, msg.id + "Room %s msg %s: Agno agent returned no response", room_id, msg_id ) - return + return response - # Surface the agent's reasoning (if any) before its actions/reply. - if Emit.THOUGHTS in self.features.emit: - await self._report_thoughts(response, tools, room_id, msg.id) + def _persist_turn(self, room_id: str, response: RunOutput) -> None: + """Persist the agent's full turn (tool calls/results + reply) for continuity. - # Report the agent's own tool executions (happened during the run, so - # before the final reply) when execution reporting is enabled. - if Emit.EXECUTION in self.features.emit: - await self._report_tool_executions(response, tools, room_id, msg.id) - - # Persist the agent's full turn (tool calls/results + reply) so the next - # message has continuity; Agno's run message list is the source of truth. + Agno's run message list is the source of truth; the system message is + dropped because Agno re-injects it from the agent's instructions. + """ if response.messages: self._message_history[room_id] = [ m for m in response.messages if m.role != "system" ] - # The agent may post via band_send_message itself. If it did, we are - # done; otherwise fall back to sending its final text so every agent - # replies regardless of whether it used the tool. + async def _send_reply( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + response: RunOutput, + *, + room_id: str, + ) -> None: + """Send the agent's text reply unless it already replied via a tool. + + Autonomous agents post through band_send_message themselves; if the agent + did not, its final text is sent as a fallback so it always responds. + """ if any( getattr(te, "tool_name", None) == "band_send_message" for te in (getattr(response, "tools", None) or []) @@ -297,10 +355,6 @@ async def on_message( ) await tools.send_message(text, mentions=mentions) - async def on_cleanup(self, room_id: str) -> None: - """Drop the room's accumulated transcript when the agent leaves.""" - self._message_history.pop(room_id, None) - def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: """Wire the in-scope Band tools into the Agno agent once. @@ -364,6 +418,7 @@ async def _report_thoughts( self, response: RunOutput, tools: AgentToolsProtocol, + *, room_id: str, msg_id: str, ) -> None: @@ -395,6 +450,7 @@ async def _report_tool_executions( self, response: RunOutput, tools: AgentToolsProtocol, + *, room_id: str, msg_id: str, ) -> None: @@ -418,48 +474,56 @@ async def _report_tool_executions( msg_id, len(executions), ) - for te in executions: - tool_call_id = getattr(te, "tool_call_id", None) or "" - tool_name = getattr(te, "tool_name", None) or "" - tool_args = getattr(te, "tool_args", None) or {} - is_error = bool(getattr(te, "tool_call_error", False)) - result = str(getattr(te, "result", "") or "") - logger.debug( - "Room %s msg %s: tool %s(%s) -> %s%s", + for execution in executions: + await self._emit_execution(execution, tools, room_id=room_id, msg_id=msg_id) + + async def _emit_execution( + self, + execution: Any, + tools: AgentToolsProtocol, + *, + room_id: str, + msg_id: str, + ) -> None: + """Emit the tool_call + tool_result event pair for one tool execution.""" + tool_call_id = getattr(execution, "tool_call_id", None) or "" + tool_name = getattr(execution, "tool_name", None) or "" + tool_args = getattr(execution, "tool_args", None) or {} + is_error = bool(getattr(execution, "tool_call_error", False)) + result = str(getattr(execution, "result", "") or "") + + logger.debug( + "Room %s msg %s: tool %s(%s) -> %s%s", + room_id, + msg_id, + tool_name, + tool_args, + result[:200], + " [error]" if is_error else "", + ) + try: + await tools.send_event( + content=json.dumps( + {"name": tool_name, "args": tool_args, "tool_call_id": tool_call_id} + ), + message_type="tool_call", + ) + await tools.send_event( + content=json.dumps( + { + "name": tool_name, + "output": result, + "tool_call_id": tool_call_id, + "is_error": is_error, + } + ), + message_type="tool_result", + ) + except Exception as e: + logger.warning( + "Room %s msg %s: failed to report tool execution %s: %s", room_id, msg_id, tool_name, - tool_args, - result[:200], - " [error]" if is_error else "", + e, ) - try: - await tools.send_event( - content=json.dumps( - { - "name": tool_name, - "args": tool_args, - "tool_call_id": tool_call_id, - } - ), - message_type="tool_call", - ) - await tools.send_event( - content=json.dumps( - { - "name": tool_name, - "output": result, - "tool_call_id": tool_call_id, - "is_error": is_error, - } - ), - message_type="tool_result", - ) - except Exception as e: - logger.warning( - "Room %s msg %s: failed to report tool execution %s: %s", - room_id, - msg_id, - tool_name, - e, - ) From fd7c655f94adb7a61849df338351b8636a380a22 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:19:30 +0300 Subject: [PATCH 24/90] refactor(agno): bind room tools via a context manager Replace the inline ContextVar set/try/finally-reset in _run_agent with a _bind_room_tools context manager, so the "room's tools are bound for the duration of the run" before/after is named, reusable, and can't drift. Reset still happens on success and error. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 208739832..9602fccdc 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -18,7 +18,8 @@ import json import logging import warnings -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager from contextvars import ContextVar from typing import TYPE_CHECKING, Any, ClassVar @@ -66,6 +67,21 @@ async def _entrypoint(**kwargs: Any) -> str: return _entrypoint +@contextmanager +def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: + """Bind the room's Band tools for the duration of an Agno run. + + Wired Band tool entrypoints read ``_current_tools`` at call time; binding it + here (and always resetting on exit) lets a single shared agent serve + concurrent rooms without their tool calls crossing over. + """ + token = _current_tools.set(tools) + try: + yield + finally: + _current_tools.reset(token) + + class AgnoAdapter(SimpleAdapter[AgnoMessages]): """ Agno framework adapter (text output + execution reporting). @@ -118,11 +134,11 @@ def __init__( ) # The caller's agent is the source of configuration. We never mutate it: - # on_started builds a deep copy (`self.agent`) that we wire Band tools - # into and run. The copy is shared across rooms/messages; Agno keeps - # per-run state in its run context, so a single instance is safe to reuse. + # on_started builds a deep copy (exposed read-only via ``agent``) that we + # wire Band tools into and run. The copy is shared across rooms/messages; + # Agno keeps per-run state in its run context, so reuse is safe. self._source_agent = agent - self.agent: AgnoAgent | None = None + self._agent: AgnoAgent | None = None # Per-room running transcript. Band delivers the rehydrated platform # history only on session bootstrap (including after a restart); later @@ -136,6 +152,12 @@ def __init__( self._warn_on_memory_collision(agent) + @property + def agent(self) -> AgnoAgent | None: + """The running Agno agent (a deep copy of the caller's), or None until + on_started. Read-only: the adapter owns and wires this instance.""" + return self._agent + def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: """Warn if Band memory was requested while Agno's own memory is enabled. @@ -166,7 +188,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: await super().on_started(agent_name, agent_description) # Run a copy so wiring Band tools never mutates the caller's object. - self.agent = self._source_agent.deep_copy() + self._agent = self._source_agent.deep_copy() # Keep the converter's own-agent filtering in sync with our identity. if isinstance(self.history_converter, AgnoHistoryConverter): @@ -288,17 +310,14 @@ async def _run_agent( msg_id, len(messages), ) - # Bind the room's tools so wired Band tools execute against this room. - token = _current_tools.set(tools) try: - response = await agent.arun(input=messages) + with _bind_room_tools(tools): + response = await agent.arun(input=messages) except Exception as e: logger.exception( "Room %s msg %s: error running Agno agent: %s", room_id, msg_id, e ) raise - finally: - _current_tools.reset(token) if response is None: logger.debug( @@ -362,14 +381,14 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: ``_current_tools`` ContextVar at call time), so they are added to the shared agent a single time on the first message. """ - if self._band_tools_wired or self.agent is None: + if self._band_tools_wired or self._agent is None: return band_tools = self._build_band_tools(tools) wired: list[str] = [] for fn in band_tools: try: - self.agent.add_tool(fn) + self._agent.add_tool(fn) wired.append(fn.name) except RuntimeError as e: # add_tool rejects when the agent's tools is a callable factory. From ec503e211008e18b3c5cbd3f25587ece281dc857 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:31:57 +0300 Subject: [PATCH 25/90] refactor(agno): consolidate lazy Agno type access in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add agno_message_class() and agno_function_class() (lru_cached, with a shared _require_agno helper) in band.converters.agno — the single home for the optional-dependency type imports. The converter and the adapter both use them, removing the duplicated per-class _message_class accessors and scattered `from agno...import` statements. Also tidy _build_run_input: collapse the room-history init with setdefault, drop the verbose bootstrap-seed log, and surface bootstrap state on the intake log instead. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 36 +++++++++++----------- src/band/converters/agno.py | 60 +++++++++++++++++++++++-------------- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 9602fccdc..293cb79cb 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -31,7 +31,12 @@ Emit, PlatformMessage, ) -from band.converters.agno import AgnoHistoryConverter, AgnoMessages +from band.converters.agno import ( + AgnoHistoryConverter, + AgnoMessages, + agno_function_class, + agno_message_class, +) if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent @@ -217,11 +222,12 @@ async def on_message( raise RuntimeError("Agno agent not initialized; on_started was not called") logger.info( - "Room %s msg %s: handling from %s (sender=%s)", + "Room %s msg %s: handling from %s (sender=%s, bootstrap=%s)", room_id, msg.id, msg.sender_name or msg.sender_type, msg.sender_id, + is_session_bootstrap, ) self._ensure_band_tools(tools) @@ -269,27 +275,22 @@ def _build_run_input( after a restart); later messages arrive empty, so the adapter keeps the running transcript itself. """ - from agno.models.message import Message - if is_session_bootstrap: self._message_history[room_id] = list(history) - logger.debug( - "Room %s msg %s: bootstrap seeded %d message(s) from rehydrated history", - room_id, - msg.id, - len(history), - ) - elif room_id not in self._message_history: - self._message_history[room_id] = [] + else: + self._message_history.setdefault(room_id, []) + message_cls = agno_message_class() messages = self._message_history[room_id] if participants_msg: messages.append( - Message(role="user", content=f"[System]: {participants_msg}") + message_cls(role="user", content=f"[System]: {participants_msg}") ) if contacts_msg: - messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) - messages.append(Message(role="user", content=msg.format_for_llm())) + messages.append( + message_cls(role="user", content=f"[System]: {contacts_msg}") + ) + messages.append(message_cls(role="user", content=msg.format_for_llm())) return messages async def _run_agent( @@ -408,8 +409,7 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: Chat/participant tools are always exposed; memory/contact tools are added when the matching capabilities are enabled. """ - from agno.tools.function import Function - + function_cls = agno_function_class() schemas = tools.get_openai_tool_schemas( include_memory=Capability.MEMORY in self.features.capabilities, include_contacts=Capability.CONTACTS in self.features.capabilities, @@ -422,7 +422,7 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: if not name: continue band_tools.append( - Function( + function_cls( name=name, description=fn.get("description", "") or "", parameters=fn.get("parameters") diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 78e55c793..e233c2811 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -4,7 +4,8 @@ import json import logging -from functools import cached_property +from functools import lru_cache +from importlib import import_module from typing import TYPE_CHECKING, Any from band.core.protocols import HistoryConverter @@ -13,14 +14,41 @@ if TYPE_CHECKING: from agno.models.message import Message + from agno.tools.function import Function logger = logging.getLogger(__name__) # Forward-referenced so this module imports without agno installed; the real -# Message type is resolved lazily via the converter's _message_class property. +# Agno types are resolved lazily via the accessors below. AgnoMessages = list["Message"] +def _require_agno(module: str, attr: str) -> Any: + """Import an Agno attribute lazily with a clear error if agno is missing.""" + try: + return getattr(import_module(module), attr) + except ImportError as e: + raise ImportError( + "Agno dependencies not installed. Install with: uv add band-sdk[agno]" + ) from e + + +@lru_cache(maxsize=1) +def agno_message_class() -> type[Message]: + """Agno's ``Message`` class, imported lazily and once. + + Single home for this optional-dependency type; shared by the converter and + the AgnoAdapter so the import lives in one place. + """ + return _require_agno("agno.models.message", "Message") + + +@lru_cache(maxsize=1) +def agno_function_class() -> type[Function]: + """Agno's ``Function`` class, imported lazily and once.""" + return _require_agno("agno.tools.function", "Function") + + class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): """ Convert platform history to Agno message format. @@ -42,21 +70,6 @@ def __init__(self, agent_name: str = ""): def set_agent_name(self, name: str) -> None: self._agent_name = name - @cached_property - def _message_class(self) -> type[Message]: - """Agno's ``Message`` class, imported lazily and once per converter. - - Keeps this module importable without agno installed; raises a clear - error only when a message is actually built. - """ - try: - from agno.models.message import Message - except ImportError as e: - raise ImportError( - "Agno dependencies not installed. Install with: uv add band-sdk[agno]" - ) from e - return Message - def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: """Dispatch each platform event to its Agno-message builder.""" messages: AgnoMessages = [] @@ -109,10 +122,9 @@ def _flush_tool_calls( """Emit buffered tool calls as one assistant message, then clear them.""" if not pending_calls: return + message_cls = agno_message_class() messages.append( - self._message_class( - role="assistant", content=None, tool_calls=list(pending_calls) - ) + message_cls(role="assistant", content=None, tool_calls=list(pending_calls)) ) pending_calls.clear() @@ -121,8 +133,9 @@ def _append_tool_result(self, messages: AgnoMessages, content: str) -> None: parsed = parse_tool_result(content) if parsed is None: return + message_cls = agno_message_class() messages.append( - self._message_class( + message_cls( role="tool", tool_call_id=parsed.tool_call_id, tool_name=parsed.name, @@ -133,12 +146,13 @@ def _append_tool_result(self, messages: AgnoMessages, content: str) -> None: def _text_message(self, hist: dict[str, Any]) -> Message: """Map a text event to a user/assistant message with sender attribution.""" + message_cls = agno_message_class() content = hist.get("content", "") if hist.get("role") == "assistant" and hist.get("sender_name") == ( self._agent_name ): - return self._message_class(role="assistant", content=content) + return message_cls(role="assistant", content=content) sender_name = hist.get("sender_name", "") formatted = f"[{sender_name}]: {content}" if sender_name else content - return self._message_class(role="user", content=formatted) + return message_cls(role="user", content=formatted) From 5d0d81a88c41784078adeb68d6533e3e95001734 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:36:55 +0300 Subject: [PATCH 26/90] style(agno): avoid continue in tool building and converter dispatch Build Band tools with a positive walrus guard (if name := fn.get("name")) and switch the converter's match default from continue to pass. Same behavior, no continue. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 22 ++++++++++------------ src/band/converters/agno.py | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 293cb79cb..c98bd7ee8 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -418,19 +418,17 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: band_tools: list[Function] = [] for schema in schemas: fn = schema.get("function", {}) - name = fn.get("name") - if not name: - continue - band_tools.append( - function_cls( - name=name, - description=fn.get("description", "") or "", - parameters=fn.get("parameters") - or {"type": "object", "properties": {}}, - entrypoint=_make_band_entrypoint(name), - skip_entrypoint_processing=True, + if name := fn.get("name"): + band_tools.append( + function_cls( + name=name, + description=fn.get("description", "") or "", + parameters=fn.get("parameters") + or {"type": "object", "properties": {}}, + entrypoint=_make_band_entrypoint(name), + skip_entrypoint_processing=True, + ) ) - ) return band_tools async def _report_thoughts( diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index e233c2811..4f5652d81 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -91,7 +91,7 @@ def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: self._flush_tool_calls(messages, pending_calls) messages.append(self._text_message(hist)) case _: - continue # skip thought and other non-text, non-tool events + pass # skip thought and other non-text, non-tool events self._flush_tool_calls(messages, pending_calls) logger.debug( From 2d7076cd84e8d4777d8885de0bf277742debbfc4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 12:42:07 +0300 Subject: [PATCH 27/90] Refactor Agno tool execution access --- src/band/adapters/agno.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index c98bd7ee8..c84c6bdc6 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -58,6 +58,16 @@ ) +def _tool_executions(response: RunOutput) -> list[Any]: + """Return Agno tool executions, normalizing absent/empty tool lists.""" + return list(getattr(response, "tools", None) or []) + + +def _tool_name(execution: Any) -> str: + """Return the tool name from an Agno execution object.""" + return getattr(execution, "tool_name", None) or "" + + def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: """Build an async Agno tool entrypoint that runs a Band platform tool.""" @@ -351,8 +361,8 @@ async def _send_reply( did not, its final text is sent as a fallback so it always responds. """ if any( - getattr(te, "tool_name", None) == "band_send_message" - for te in (getattr(response, "tools", None) or []) + _tool_name(execution) == "band_send_message" + for execution in _tool_executions(response) ): logger.debug( "Room %s msg %s: agent replied via band_send_message", room_id, msg.id @@ -478,9 +488,9 @@ async def _report_tool_executions( reply (and duplicate it on rehydration). """ executions = [ - te - for te in (getattr(response, "tools", None) or []) - if (getattr(te, "tool_name", None) or "") not in _SELF_REPORTING_TOOLS + execution + for execution in _tool_executions(response) + if _tool_name(execution) not in _SELF_REPORTING_TOOLS ] if not executions: return From d697af2c5389f7f2338b96f1c209a5ac9cb9ce30 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 13:35:55 +0300 Subject: [PATCH 28/90] Simplify Agno adapter comments --- src/band/adapters/agno.py | 170 ++++++++---------------------------- src/band/converters/agno.py | 68 +++++---------- 2 files changed, 59 insertions(+), 179 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index c84c6bdc6..cc5213db1 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -1,17 +1,4 @@ -""" -Agno adapter using the SimpleAdapter pattern. - -Agno is model-agnostic: the developer builds and configures their own Agno -``Agent`` (model, instructions, tools, reasoning, ...) and hands it to this -adapter. The adapter simply bridges it to Band — it converts Band history to -Agno messages, runs the developer's agent, and sends the text reply back. - -Unlike adapters that run an explicit tool-calling loop, Agno owns its own agent -loop internally: ``Agent.arun(input=...)`` accepts a list of Agno messages and -returns a run output whose ``.content`` is the final text. The Band toolset is -exposed to the agent so it can send messages and act on the platform itself; -tool executions are reported to the room when ``Emit.EXECUTION`` is enabled. -""" +"""Agno adapter using the SimpleAdapter pattern.""" from __future__ import annotations @@ -21,7 +8,8 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar -from typing import TYPE_CHECKING, Any, ClassVar +from functools import wraps +from typing import TYPE_CHECKING, Any, ClassVar, Concatenate, ParamSpec, TypeVar from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter @@ -46,31 +34,40 @@ logger = logging.getLogger(__name__) -# Tools whose effect is already a visible room message/event, so their -# execution must not be re-reported as tool_call/tool_result events. +P = ParamSpec("P") +R = TypeVar("R") + +# These tools already produce visible room output. _SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) -# The Band tools handle for the room being processed. Wired Band tools read it -# at call time so a single shared Agno agent can serve concurrent rooms — each -# on_message coroutine sets its own value (ContextVars are task-isolated). +# Current room tools for wired Agno tool entrypoints. _current_tools: ContextVar[AgentToolsProtocol | None] = ContextVar( "agno_current_tools", default=None ) def _tool_executions(response: RunOutput) -> list[Any]: - """Return Agno tool executions, normalizing absent/empty tool lists.""" return list(getattr(response, "tools", None) or []) def _tool_name(execution: Any) -> str: - """Return the tool name from an Agno execution object.""" return getattr(execution, "tool_name", None) or "" -def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: - """Build an async Agno tool entrypoint that runs a Band platform tool.""" +def _with_agent( + fn: Callable[Concatenate[Any, AgnoAgent, P], Awaitable[R]], +) -> Callable[Concatenate[Any, P], Awaitable[R]]: + @wraps(fn) + async def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: + agent = getattr(self, "_agent", None) + if agent is None: + raise RuntimeError("AgnoAdapter was used before on_started()") + return await fn(self, agent, *args, **kwargs) + return wrapper + + +def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: async def _entrypoint(**kwargs: Any) -> str: active = _current_tools.get() if active is None: @@ -84,12 +81,7 @@ async def _entrypoint(**kwargs: Any) -> str: @contextmanager def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: - """Bind the room's Band tools for the duration of an Agno run. - - Wired Band tool entrypoints read ``_current_tools`` at call time; binding it - here (and always resetting on exit) lets a single shared agent serve - concurrent rooms without their tool calls crossing over. - """ + """Bind room tools for one Agno run.""" token = _current_tools.set(tools) try: yield @@ -98,40 +90,11 @@ def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: class AgnoAdapter(SimpleAdapter[AgnoMessages]): - """ - Agno framework adapter (text output + execution reporting). - - Takes a developer-built Agno ``Agent`` and bridges it to Band. Stateless per - room: Band history is the source of truth and is passed as input on every - message. - - The Band toolset is exposed to the agent — chat and participant tools always, - plus memory/contact tools when the matching capabilities are enabled — so it - can send messages, invite peers, and act on the platform itself. If the agent - does not post via ``band_send_message``, its final text is sent as a fallback, - so simple agents still reply without any Band-specific prompting. - - Tool executions are reported to the room as tool_call/tool_result events when - ``Emit.EXECUTION`` is enabled. - - Example: - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are a helpful assistant.", - ) - adapter = AgnoAdapter(agno_agent) - agent = Agent.create(adapter=adapter, agent_id="...", api_key="...") - await agent.run() - """ + """Bridge a developer-built Agno agent to Band.""" - # Can report the agent's tool executions and reasoning to the room. SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset( {Emit.EXECUTION, Emit.THOUGHTS} ) - # Can expose Band memory/contact tools to the Agno agent. SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( {Capability.MEMORY, Capability.CONTACTS} ) @@ -148,38 +111,23 @@ def __init__( features=features, ) - # The caller's agent is the source of configuration. We never mutate it: - # on_started builds a deep copy (exposed read-only via ``agent``) that we - # wire Band tools into and run. The copy is shared across rooms/messages; - # Agno keeps per-run state in its run context, so reuse is safe. + # Keep caller configuration immutable; runtime wiring happens on the copy. self._source_agent = agent self._agent: AgnoAgent | None = None - # Per-room running transcript. Band delivers the rehydrated platform - # history only on session bootstrap (including after a restart); later - # messages arrive with empty history, so the adapter accumulates the - # conversation itself and feeds it to Agno on every run. + # Running per-room transcripts; bootstrap history seeds each room. self._message_history: dict[str, list[Message]] = {} - - # Band capability tools (memory/contacts) are wired into the copy once, - # on the first message, since they are room-agnostic. self._band_tools_wired = False self._warn_on_memory_collision(agent) @property def agent(self) -> AgnoAgent | None: - """The running Agno agent (a deep copy of the caller's), or None until - on_started. Read-only: the adapter owns and wires this instance.""" + """The running Agno agent, initialized in on_started.""" return self._agent def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: - """Warn if Band memory was requested while Agno's own memory is enabled. - - Only relevant when the caller enabled ``Capability.MEMORY``: the adapter - then exposes Band memory tools to the agent, which collides with Agno's - built-in memory (``update_memory_on_run`` / ``enable_agentic_memory``). - """ + """Warn when Band and Agno memory are both enabled.""" if Capability.MEMORY not in self.features.capabilities: return @@ -199,16 +147,11 @@ def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: ) async def on_started(self, agent_name: str, agent_description: str) -> None: - """Deep-copy the caller's agent and sync the converter identity.""" + """Deep-copy the caller's agent.""" await super().on_started(agent_name, agent_description) - # Run a copy so wiring Band tools never mutates the caller's object. self._agent = self._source_agent.deep_copy() - # Keep the converter's own-agent filtering in sync with our identity. - if isinstance(self.history_converter, AgnoHistoryConverter): - self.history_converter.set_agent_name(agent_name) - logger.info("Agno adapter started for agent: %s", agent_name) logger.debug( "Agno adapter features: emit=%s capabilities=%s", @@ -228,9 +171,6 @@ async def on_message( room_id: str, ) -> None: """Run the developer's Agno agent and ensure a reply is sent.""" - if self.agent is None: - raise RuntimeError("Agno agent not initialized; on_started was not called") - logger.info( "Room %s msg %s: handling from %s (sender=%s, bootstrap=%s)", room_id, @@ -279,12 +219,7 @@ def _build_run_input( is_session_bootstrap: bool, room_id: str, ) -> list[Message]: - """Seed the per-room transcript and append the new system/user messages. - - Band delivers the rehydrated platform history only on bootstrap (incl. - after a restart); later messages arrive empty, so the adapter keeps the - running transcript itself. - """ + """Build Agno input for this turn.""" if is_session_bootstrap: self._message_history[room_id] = list(history) else: @@ -303,8 +238,10 @@ def _build_run_input( messages.append(message_cls(role="user", content=msg.format_for_llm())) return messages + @_with_agent async def _run_agent( self, + agent: AgnoAgent, messages: list[Message], tools: AgentToolsProtocol, *, @@ -312,9 +249,6 @@ async def _run_agent( msg_id: str, ) -> RunOutput | None: """Run the Agno agent with the room's tools bound for this call.""" - agent = self.agent - assert agent is not None # on_message guarantees the agent is initialized - logger.debug( "Room %s msg %s: running Agno agent (%d input messages)", room_id, @@ -337,11 +271,7 @@ async def _run_agent( return response def _persist_turn(self, room_id: str, response: RunOutput) -> None: - """Persist the agent's full turn (tool calls/results + reply) for continuity. - - Agno's run message list is the source of truth; the system message is - dropped because Agno re-injects it from the agent's instructions. - """ + """Persist Agno's transcript, excluding generated system messages.""" if response.messages: self._message_history[room_id] = [ m for m in response.messages if m.role != "system" @@ -355,11 +285,7 @@ async def _send_reply( *, room_id: str, ) -> None: - """Send the agent's text reply unless it already replied via a tool. - - Autonomous agents post through band_send_message themselves; if the agent - did not, its final text is sent as a fallback so it always responds. - """ + """Send final text unless the agent already posted through Band.""" if any( _tool_name(execution) == "band_send_message" for execution in _tool_executions(response) @@ -374,7 +300,6 @@ async def _send_reply( logger.debug("Room %s msg %s: agent produced no reply", room_id, msg.id) return - # mentions accepts handles/names/IDs as strings; the SDK resolves them. mentions = [msg.sender_id] logger.info( "Room %s msg %s: sending reply (%d chars), mentions=%s", @@ -386,12 +311,7 @@ async def _send_reply( await tools.send_message(text, mentions=mentions) def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: - """Wire the in-scope Band tools into the Agno agent once. - - These tools are room-agnostic (the active room is supplied via the - ``_current_tools`` ContextVar at call time), so they are added to the - shared agent a single time on the first message. - """ + """Wire Band tools into the copied Agno agent once.""" if self._band_tools_wired or self._agent is None: return @@ -402,7 +322,6 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: self._agent.add_tool(fn) wired.append(fn.name) except RuntimeError as e: - # add_tool rejects when the agent's tools is a callable factory. logger.warning("Could not wire Band tool %s: %s", fn.name, e) if wired: logger.info( @@ -410,15 +329,10 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: len(wired), ", ".join(wired), ) - # Synchronous, no await: safe to mark wired even across concurrent calls. self._band_tools_wired = True def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: - """Convert the in-scope Band tool schemas into Agno Functions. - - Chat/participant tools are always exposed; memory/contact tools are added - when the matching capabilities are enabled. - """ + """Convert Band tool schemas into Agno Functions.""" function_cls = agno_function_class() schemas = tools.get_openai_tool_schemas( include_memory=Capability.MEMORY in self.features.capabilities, @@ -449,12 +363,7 @@ async def _report_thoughts( room_id: str, msg_id: str, ) -> None: - """Post the agent's reasoning content as a thought event. - - Only produces output when the developer's Agno agent has reasoning - enabled (e.g. ``reasoning=True`` or a reasoning model); otherwise - ``reasoning_content`` is empty and nothing is posted. - """ + """Post Agno reasoning as a thought event.""" reasoning = getattr(response, "reasoning_content", None) text = (reasoning or "").strip() if isinstance(reasoning, str) else "" if not text: @@ -481,12 +390,7 @@ async def _report_tool_executions( room_id: str, msg_id: str, ) -> None: - """Emit tool_call/tool_result events for the agent's tool executions. - - Skips band_send_message/band_send_event: their effect is already a - visible room message/event, so reporting them would double-record the - reply (and duplicate it on rehydration). - """ + """Emit tool_call/tool_result events for reportable executions.""" executions = [ execution for execution in _tool_executions(response) diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 4f5652d81..5b94d0a70 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -18,13 +18,11 @@ logger = logging.getLogger(__name__) -# Forward-referenced so this module imports without agno installed; the real -# Agno types are resolved lazily via the accessors below. +# Forward reference keeps agno optional at import time. AgnoMessages = list["Message"] def _require_agno(module: str, attr: str) -> Any: - """Import an Agno attribute lazily with a clear error if agno is missing.""" try: return getattr(import_module(module), attr) except ImportError as e: @@ -35,46 +33,39 @@ def _require_agno(module: str, attr: str) -> Any: @lru_cache(maxsize=1) def agno_message_class() -> type[Message]: - """Agno's ``Message`` class, imported lazily and once. - - Single home for this optional-dependency type; shared by the converter and - the AgnoAdapter so the import lives in one place. - """ + """Agno Message class.""" return _require_agno("agno.models.message", "Message") @lru_cache(maxsize=1) def agno_function_class() -> type[Function]: - """Agno's ``Function`` class, imported lazily and once.""" + """Agno Function class.""" return _require_agno("agno.tools.function", "Function") -class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): - """ - Convert platform history to Agno message format. +def _flush_tool_calls( + messages: AgnoMessages, pending_calls: list[dict[str, Any]] +) -> None: + if not pending_calls: + return + message_cls = agno_message_class() + messages.append( + message_cls(role="assistant", content=None, tool_calls=list(pending_calls)) + ) + pending_calls.clear() - Output: - - this agent's text messages -> Message(role="assistant", content=...) - - everyone else's text messages -> Message(role="user", content="[name]: ...") - - tool_call events -> Message(role="assistant", tool_calls=[{id, type, function}]) - (consecutive calls are batched into one assistant message) - - tool_result events -> Message(role="tool", tool_call_id=..., content=output) - This is Agno's own history shape, so rehydrated tool turns round-trip back - through ``Agent.arun(input=...)`` for whichever model the agent uses. - """ +class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): + """Convert platform history to Agno messages.""" - def __init__(self, agent_name: str = ""): + def __init__(self, agent_name: str = "") -> None: self._agent_name = agent_name def set_agent_name(self, name: str) -> None: self._agent_name = name def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: - """Dispatch each platform event to its Agno-message builder.""" messages: AgnoMessages = [] - # Buffer consecutive tool calls so they land in a single assistant - # message (matching how Agno emits parallel tool calls). pending_calls: list[dict[str, Any]] = [] for hist in raw: @@ -84,16 +75,15 @@ def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: if call is not None: pending_calls.append(call) case "tool_result": - # The assistant tool_calls message must precede its results. - self._flush_tool_calls(messages, pending_calls) + _flush_tool_calls(messages, pending_calls) self._append_tool_result(messages, hist.get("content", "")) case "text": - self._flush_tool_calls(messages, pending_calls) + _flush_tool_calls(messages, pending_calls) messages.append(self._text_message(hist)) case _: - pass # skip thought and other non-text, non-tool events + pass - self._flush_tool_calls(messages, pending_calls) + _flush_tool_calls(messages, pending_calls) logger.debug( "Converted %d platform event(s) into %d Agno message(s)", len(raw), @@ -103,7 +93,6 @@ def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: @staticmethod def _tool_call_dict(content: str) -> dict[str, Any] | None: - """Shape a tool_call event into an Agno (OpenAI-style) tool call.""" parsed = parse_tool_call(content) if parsed is None: return None @@ -116,20 +105,8 @@ def _tool_call_dict(content: str) -> dict[str, Any] | None: }, } - def _flush_tool_calls( - self, messages: AgnoMessages, pending_calls: list[dict[str, Any]] - ) -> None: - """Emit buffered tool calls as one assistant message, then clear them.""" - if not pending_calls: - return - message_cls = agno_message_class() - messages.append( - message_cls(role="assistant", content=None, tool_calls=list(pending_calls)) - ) - pending_calls.clear() - - def _append_tool_result(self, messages: AgnoMessages, content: str) -> None: - """Append a tool_result event as a tool-role message.""" + @staticmethod + def _append_tool_result(messages: AgnoMessages, content: str) -> None: parsed = parse_tool_result(content) if parsed is None: return @@ -145,7 +122,6 @@ def _append_tool_result(self, messages: AgnoMessages, content: str) -> None: ) def _text_message(self, hist: dict[str, Any]) -> Message: - """Map a text event to a user/assistant message with sender attribution.""" message_cls = agno_message_class() content = hist.get("content", "") if hist.get("role") == "assistant" and hist.get("sender_name") == ( From ce4080ea6ca845e148c8f26c628c1e5a545044ef Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 13:52:02 +0300 Subject: [PATCH 29/90] fix(agno): tag converted history messages with from_history=True Agno 2.6.16 uses any(msg.from_history for msg in input) to decide whether the input already carries history; our converter built rehydrated Band history with the default from_history=False, so an agent with its own db/session history could re-add stored history on top of Band history (duplicate context / repeated tool transcripts on bootstrap). Tag all converter-produced messages from_history=True; the adapter's current-turn message stays False. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/converters/agno.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 5b94d0a70..20e5444e6 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -50,7 +50,12 @@ def _flush_tool_calls( return message_cls = agno_message_class() messages.append( - message_cls(role="assistant", content=None, tool_calls=list(pending_calls)) + message_cls( + role="assistant", + content=None, + tool_calls=list(pending_calls), + from_history=True, + ) ) pending_calls.clear() @@ -118,17 +123,20 @@ def _append_tool_result(messages: AgnoMessages, content: str) -> None: tool_name=parsed.name, content=parsed.output, tool_call_error=parsed.is_error, + from_history=True, ) ) def _text_message(self, hist: dict[str, Any]) -> Message: + # Converter output is rehydrated history; tag it so Agno's + # any(msg.from_history) check doesn't re-add stored session history. message_cls = agno_message_class() content = hist.get("content", "") if hist.get("role") == "assistant" and hist.get("sender_name") == ( self._agent_name ): - return message_cls(role="assistant", content=content) + return message_cls(role="assistant", content=content, from_history=True) sender_name = hist.get("sender_name", "") formatted = f"[{sender_name}]: {content}" if sender_name else content - return message_cls(role="user", content=formatted) + return message_cls(role="user", content=formatted, from_history=True) From 21fe2afeb8f3bb0fe1a00f5d35d2e0765f19f585 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 13:54:31 +0300 Subject: [PATCH 30/90] fix(agno): restore converter agent-name sync in on_started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refactor dropped the set_agent_name call, leaving the converter's _agent_name empty so the own-agent check never matched — on restart the agent's own past replies rehydrated as "[Name]: ..." user messages instead of assistant turns. Restore the sync in on_started. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index cc5213db1..922f1ac95 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -147,11 +147,16 @@ def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: ) async def on_started(self, agent_name: str, agent_description: str) -> None: - """Deep-copy the caller's agent.""" + """Deep-copy the caller's agent and sync the converter identity.""" await super().on_started(agent_name, agent_description) self._agent = self._source_agent.deep_copy() + # Keep the converter's own-agent filtering in sync with our identity, so + # rehydrated history maps this agent's past messages to the assistant role. + if isinstance(self.history_converter, AgnoHistoryConverter): + self.history_converter.set_agent_name(agent_name) + logger.info("Agno adapter started for agent: %s", agent_name) logger.debug( "Agno adapter features: emit=%s capabilities=%s", From b31b608566dafbec3ca525232ea3b72f14d0db91 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 14:00:26 +0300 Subject: [PATCH 31/90] Add Agno to dev dependencies --- pyproject.toml | 2 ++ uv.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1c7915cb8..bb3462580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,8 @@ dev = [ "google-genai>=1.43.0", # Include google-adk for testing "google-adk>=1.0.0,<2", + # Include Agno for testing + "agno>=2.6.0", # Include bridge deps for testing "aiohttp>=3.9,<4", "python-dotenv>=1.2.2", diff --git a/uv.lock b/uv.lock index 95c9fc9be..743cc6120 100644 --- a/uv.lock +++ b/uv.lock @@ -570,6 +570,7 @@ crewai = [ dev = [ { name = "a2a-sdk" }, { name = "agent-client-protocol" }, + { name = "agno" }, { name = "aiohttp" }, { name = "anthropic" }, { name = "beautifulsoup4" }, @@ -674,6 +675,7 @@ requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0" }, { name = "agent-client-protocol", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "agno", marker = "extra == 'agno'", specifier = ">=2.6.0" }, + { name = "agno", marker = "extra == 'dev'", specifier = ">=2.6.0" }, { name = "aiohttp", marker = "extra == 'bridge'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'bridge-agentcore'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'dev'", specifier = ">=3.9,<4" }, From 992bc527f4c74466d848947a29878563ffc5715d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 14:06:24 +0300 Subject: [PATCH 32/90] refactor(agno): allowlist conversation roles when persisting turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _persist_turn previously denylisted only role == "system", which assumed the instructions message is the sole per-run-regenerated message. That breaks if the agent uses a non-default system_message_role (e.g. "developer") or enables per-run context injections (datetime/location/state/summaries) — those would be replayed alongside freshly injected copies. Allowlist {user, assistant, tool} instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 922f1ac95..b59b12253 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -40,6 +40,11 @@ # These tools already produce visible room output. _SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) +# Conversation roles to persist across turns. Allowlisting these drops Agno's +# per-run injected messages (system/developer instructions, datetime/state +# context, summaries) so they are not replayed alongside freshly injected ones. +_CONVERSATION_ROLES = frozenset({"user", "assistant", "tool"}) + # Current room tools for wired Agno tool entrypoints. _current_tools: ContextVar[AgentToolsProtocol | None] = ContextVar( "agno_current_tools", default=None @@ -276,14 +281,20 @@ async def _run_agent( return response def _persist_turn(self, room_id: str, response: RunOutput) -> None: - """Persist Agno's transcript, excluding generated system messages.""" + """Persist Agno's transcript, keeping only conversation messages. + + Allowlisting conversation roles drops Agno's per-run injected messages + (instructions, context, summaries) so they are not replayed alongside + the freshly injected ones on the next run. + """ if response.messages: self._message_history[room_id] = [ - m for m in response.messages if m.role != "system" + m for m in response.messages if m.role in _CONVERSATION_ROLES ] + @classmethod async def _send_reply( - self, + cls, msg: PlatformMessage, tools: AgentToolsProtocol, response: RunOutput, @@ -360,8 +371,9 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: ) return band_tools + @classmethod async def _report_thoughts( - self, + cls, response: RunOutput, tools: AgentToolsProtocol, *, @@ -413,8 +425,9 @@ async def _report_tool_executions( for execution in executions: await self._emit_execution(execution, tools, room_id=room_id, msg_id=msg_id) + @classmethod async def _emit_execution( - self, + cls, execution: Any, tools: AgentToolsProtocol, *, From ae58a22f67d22b891a895d2af5ad5b55ea365644 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 14:44:06 +0300 Subject: [PATCH 33/90] test(agno): add adapter, converter, and rehydration unit tests Add framework-specific unit tests for the Agno adapter and converter, covering behavior the conformance suite cannot assert. Adapter (tests/adapters/agno/): deep-copy on start, memory-collision warning, Band-tool wiring + capability gating, the ContextVar tool binding, fallback-send, EXECUTION/THOUGHTS emit reporting, transcript persistence, and cleanup. Rehydration tests drive the real on_event -> converter path and inspect the exact run input Agno receives: all message kinds map to the right messages, unsupported kinds are dropped, history is tagged from_history while the current message stays live, unanswered messages are excluded from replay yet answered, and the persisted transcript carries across turns. Converter (tests/converters/test_agno.py): tool_call/tool_result Message shape, JSON-string arguments, batching/flush, own-agent role mapping, the from_history invariant, and malformed/unknown input handling. Tests reuse FakeAgentTools, the sample_platform_message fixture, shared tool-event fixtures, and the real format_history_for_llm; only the Agno agent's network boundary is faked. Layout follows the langgraph package convention (helpers.py + concern-split modules). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/adapters/agno/__init__.py | 0 tests/adapters/agno/helpers.py | 155 ++++++++ tests/adapters/agno/test_adapter.py | 452 ++++++++++++++++++++++++ tests/adapters/agno/test_rehydration.py | 261 ++++++++++++++ tests/converters/test_agno.py | 188 ++++++++++ 5 files changed, 1056 insertions(+) create mode 100644 tests/adapters/agno/__init__.py create mode 100644 tests/adapters/agno/helpers.py create mode 100644 tests/adapters/agno/test_adapter.py create mode 100644 tests/adapters/agno/test_rehydration.py create mode 100644 tests/converters/test_agno.py diff --git a/tests/adapters/agno/__init__.py b/tests/adapters/agno/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py new file mode 100644 index 000000000..698448e50 --- /dev/null +++ b/tests/adapters/agno/helpers.py @@ -0,0 +1,155 @@ +"""Shared helpers for the Agno adapter tests. + +The adapter never calls an LLM directly: it deep-copies the developer's Agno +agent in ``on_started`` and calls ``agent.arun(...)`` per turn. So the only thing +faked here is the Agno agent (``deep_copy`` / ``add_tool`` / ``arun``); everything +the adapter reads off the run is a real Agno ``RunOutput`` / ``Message`` / +``ToolExecution``. The Band side uses ``FakeAgentTools`` so calls are tracked +without a mocking framework. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from agno.models.message import Message +from agno.models.response import ToolExecution +from agno.run.agent import RunOutput + +from band.adapters.agno import AgnoAdapter +from band.core.types import ( + AdapterFeatures, + AgentInput, + HistoryProvider, + PlatformMessage, +) +from band.testing import FakeAgentTools + + +def make_agno_agent( + *, + update_memory_on_run: bool = False, + enable_agentic_memory: bool = False, + response: RunOutput | None = None, +) -> tuple[MagicMock, MagicMock]: + """Return (source_agent, copied_agent) fakes. + + ``deep_copy()`` returns the copy, mirroring how the adapter runs against a + copy of the developer's agent. The copy's ``arun`` yields ``response``. + """ + source = MagicMock(name="source_agent") + source.update_memory_on_run = update_memory_on_run + source.enable_agentic_memory = enable_agentic_memory + + copy = MagicMock(name="copied_agent") + copy.add_tool = MagicMock() + copy.arun = AsyncMock( + return_value=response if response is not None else RunOutput() + ) + source.deep_copy = MagicMock(return_value=copy) + return source, copy + + +def tool_execution( + name: str, + *, + call_id: str = "tc_1", + args: dict[str, Any] | None = None, + result: str = "", + error: bool = False, +) -> ToolExecution: + return ToolExecution( + tool_name=name, + tool_call_id=call_id, + tool_args=args or {}, + result=result, + tool_call_error=error, + ) + + +async def started( + response: RunOutput | None = None, + *, + features: AdapterFeatures | None = None, +) -> tuple[AgnoAdapter, MagicMock]: + """Build an adapter past ``on_started`` and return (adapter, copied_agent).""" + source, copy = make_agno_agent(response=response) + adapter = AgnoAdapter(source, features=features) + await adapter.on_started("TestBot", "desc") + return adapter, copy + + +class SchemaTools(FakeAgentTools): + """FakeAgentTools that returns real OpenAI-format schemas and records the + capability flags it was asked for (FakeAgentTools returns [] by default).""" + + def __init__(self, schemas: list[dict[str, Any]], **kwargs: Any) -> None: + super().__init__(**kwargs) + self._schemas = schemas + self.schema_calls: list[dict[str, bool]] = [] + + def get_openai_tool_schemas( + self, *, include_memory: bool = False, include_contacts: bool = True + ) -> list[dict[str, Any]]: + self.schema_calls.append( + {"include_memory": include_memory, "include_contacts": include_contacts} + ) + return self._schemas + + +def openai_tool_schema(name: str) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": name, + "description": f"{name} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + + +def make_agent_input( + msg: PlatformMessage, + raw: list[dict[str, Any]], + *, + is_session_bootstrap: bool, + participants_msg: str | None = None, + contacts_msg: str | None = None, + tools: FakeAgentTools | None = None, +) -> AgentInput: + """Build an AgentInput so tests drive the real on_event -> converter path.""" + return AgentInput( + msg=msg, + tools=tools or FakeAgentTools(), + history=HistoryProvider(raw=raw), + participants_msg=participants_msg, + contacts_msg=contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=msg.room_id, + ) + + +def run_input(copy: MagicMock) -> list[Message]: + """The exact list[Message] the faked Agno agent received via arun(input=...).""" + return copy.arun.await_args.kwargs["input"] + + +def platform_msg( + msg_id: str, + content: str, + *, + sender_type: str = "User", + sender_name: str = "Alice", + message_type: str = "text", +) -> dict[str, Any]: + """A platform-shaped history dict, as the REST context API would return it.""" + return { + "id": msg_id, + "content": content, + "sender_id": f"id-{msg_id}", + "sender_type": sender_type, + "sender_name": sender_name, + "message_type": message_type, + "metadata": {}, + } diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py new file mode 100644 index 000000000..255b3c0cc --- /dev/null +++ b/tests/adapters/agno/test_adapter.py @@ -0,0 +1,452 @@ +"""Agno adapter behavior tests. + +Conformance already covers init defaults, ``on_started`` name/description, and +generic converter wiring; these tests pin Agno-only behavior: agent deep-copy, +memory-collision warning, Band-tool wiring, the ContextVar tool binding, +fallback-send, emit reporting, transcript persistence, and cleanup. Rehydration +of platform history lives in ``test_rehydration.py``. +""" + +from __future__ import annotations + +import json +import warnings +from typing import Any + +import pytest +from agno.models.message import Message +from agno.run.agent import RunOutput + +from band.adapters.agno import ( + AgnoAdapter, + _bind_room_tools, + _make_band_entrypoint, +) +from band.core.types import AdapterFeatures, Capability, Emit +from band.testing import FakeAgentTools + +from .helpers import ( + SchemaTools, + make_agno_agent, + openai_tool_schema, + started, + tool_execution, +) + + +class TestOnStarted: + async def test_runs_against_a_deep_copy_not_the_source(self): + source, copy = make_agno_agent() + adapter = AgnoAdapter(source) + + await adapter.on_started("TestBot", "desc") + + source.deep_copy.assert_called_once() + assert adapter.agent is copy + assert adapter.agent is not source + + async def test_syncs_converter_identity(self): + adapter, _ = await started() + + assert adapter.history_converter._agent_name == "TestBot" + + +class TestMemoryCollisionWarning: + def test_warns_on_update_memory_on_run_with_memory_capability(self): + source, _ = make_agno_agent(update_memory_on_run=True) + + with pytest.warns(UserWarning, match="update_memory_on_run"): + AgnoAdapter( + source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + ) + + def test_warns_on_agentic_memory_with_memory_capability(self): + source, _ = make_agno_agent(enable_agentic_memory=True) + + with pytest.warns(UserWarning, match="enable_agentic_memory"): + AgnoAdapter( + source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + ) + + def test_no_warning_without_memory_capability(self): + source, _ = make_agno_agent( + update_memory_on_run=True, enable_agentic_memory=True + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + AgnoAdapter(source) # no MEMORY capability -> no collision + + +class TestBandToolWiring: + async def test_wires_each_schema_once(self, sample_platform_message): + tools = SchemaTools( + [ + openai_tool_schema("band_send_message"), + openai_tool_schema("band_lookup_peers"), + ] + ) + adapter, copy = await started() + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + # Second turn must not re-wire (the _band_tools_wired guard). + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert copy.add_tool.call_count == 2 + wired_names = [call.args[0].name for call in copy.add_tool.call_args_list] + assert wired_names == ["band_send_message", "band_lookup_peers"] + + async def test_capability_flags_drive_schema_request(self, sample_platform_message): + tools = SchemaTools([]) + adapter, _ = await started( + features=AdapterFeatures( + capabilities={Capability.MEMORY, Capability.CONTACTS} + ) + ) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.schema_calls == [ + {"include_memory": True, "include_contacts": True} + ] + + async def test_no_capabilities_excludes_memory_and_contacts( + self, sample_platform_message + ): + tools = SchemaTools([]) + adapter, _ = await started() + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.schema_calls == [ + {"include_memory": False, "include_contacts": False} + ] + + +class TestBandEntrypointBinding: + async def test_routes_to_execute_tool_call_inside_context(self): + tools = FakeAgentTools() + entry = _make_band_entrypoint("band_lookup_peers") + + with _bind_room_tools(tools): + result = await entry(page=1) + + assert tools.tool_calls == [ + {"tool_name": "band_lookup_peers", "arguments": {"page": 1}} + ] + assert json.loads(result) == {"status": "ok"} + + async def test_passes_string_results_through_unchanged(self): + class _StrTools(FakeAgentTools): + async def execute_tool_call(self, tool_name: str, arguments: dict) -> Any: + return "raw-string" + + entry = _make_band_entrypoint("band_lookup_peers") + with _bind_room_tools(_StrTools()): + assert await entry() == "raw-string" + + async def test_errors_outside_any_bound_context(self): + tools = FakeAgentTools() + entry = _make_band_entrypoint("band_lookup_peers") + + # Bind then exit; the ContextVar must reset so later calls have no tools. + with _bind_room_tools(tools): + pass + result = await entry(page=1) + + assert "no active Band context" in result + assert tools.tool_calls == [] + + +class TestReply: + async def test_sends_fallback_text_when_agent_did_not_post( + self, sample_platform_message + ): + tools = FakeAgentTools() + adapter, _ = await started(RunOutput(content="hello")) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_message_sent(content="hello", mentions=["user-456"]) + + async def test_skips_fallback_when_agent_called_band_send_message( + self, sample_platform_message + ): + tools = FakeAgentTools() + response = RunOutput( + content="hello", tools=[tool_execution("band_send_message")] + ) + adapter, _ = await started(response) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_no_messages_sent() + + async def test_no_send_for_empty_content(self, sample_platform_message): + tools = FakeAgentTools() + adapter, _ = await started(RunOutput(content=" ")) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_no_messages_sent() + + +class TestEmitExecution: + async def test_emits_tool_call_and_result_events(self, sample_platform_message): + tools = FakeAgentTools() + response = RunOutput( + tools=[tool_execution("band_lookup_peers", args={"page": "1"}, result="ok")] + ) + adapter, _ = await started( + response, features=AdapterFeatures(emit={Emit.EXECUTION}) + ) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + types = [e["message_type"] for e in tools.events_sent] + assert types == ["tool_call", "tool_result"] + call_payload = json.loads(tools.events_sent[0]["content"]) + result_payload = json.loads(tools.events_sent[1]["content"]) + assert call_payload == { + "name": "band_lookup_peers", + "args": {"page": "1"}, + "tool_call_id": "tc_1", + } + assert result_payload["output"] == "ok" + assert result_payload["is_error"] is False + + async def test_self_reporting_tools_are_not_re_emitted( + self, sample_platform_message + ): + tools = FakeAgentTools() + response = RunOutput(tools=[tool_execution("band_send_message")]) + adapter, _ = await started( + response, features=AdapterFeatures(emit={Emit.EXECUTION}) + ) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.events_sent == [] + + async def test_no_events_without_execution_emit(self, sample_platform_message): + tools = FakeAgentTools() + response = RunOutput(tools=[tool_execution("band_lookup_peers")]) + adapter, _ = await started(response) # no emit configured + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.events_sent == [] + + +class TestEmitThoughts: + async def test_emits_reasoning_as_thought(self, sample_platform_message): + tools = FakeAgentTools() + response = RunOutput(reasoning_content="thinking hard") + adapter, _ = await started( + response, features=AdapterFeatures(emit={Emit.THOUGHTS}) + ) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_event_sent(message_type="thought") + assert tools.events_sent[0]["content"] == "thinking hard" + + async def test_no_thought_without_thoughts_emit(self, sample_platform_message): + tools = FakeAgentTools() + response = RunOutput(reasoning_content="thinking hard") + adapter, _ = await started(response) # no emit configured + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.events_sent == [] + + async def test_no_thought_for_blank_reasoning(self, sample_platform_message): + tools = FakeAgentTools() + adapter, _ = await started( + RunOutput(reasoning_content=" "), + features=AdapterFeatures(emit={Emit.THOUGHTS}), + ) + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert tools.events_sent == [] + + +class TestPersistAndAccumulate: + def test_persist_keeps_only_conversation_roles(self): + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + response = RunOutput( + messages=[ + Message(role="system", content="instructions"), + Message(role="user", content="hi"), + Message(role="assistant", content="hello"), + Message(role="developer", content="state"), + Message(role="tool", content="result"), + ] + ) + + adapter._persist_turn("room-1", response) + + kept = [m.role for m in adapter._message_history["room-1"]] + assert kept == ["user", "assistant", "tool"] + + def test_bootstrap_seeds_then_followup_accumulates(self, sample_platform_message): + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + seed = [Message(role="user", content="earlier")] + + adapter._build_run_input( + sample_platform_message, + seed, + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + adapter._build_run_input( + sample_platform_message, + [], + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + transcript = adapter._message_history["room-1"] + # seed + bootstrap user msg + follow-up user msg + assert len(transcript) == 3 + assert transcript[0].content == "earlier" + assert all(m.role == "user" for m in transcript) + + +class TestOnCleanup: + async def test_drops_room_transcript(self): + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + adapter._message_history["room-1"] = [Message(role="user", content="hi")] + + await adapter.on_cleanup("room-1") + + assert "room-1" not in adapter._message_history + + async def test_unknown_room_is_noop(self): + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + + await adapter.on_cleanup("never-seen") # must not raise + + +class TestUsedBeforeStarted: + async def test_run_agent_before_on_started_raises(self): + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + + with pytest.raises(RuntimeError, match="before on_started"): + await adapter._run_agent( + [], FakeAgentTools(), room_id="room-1", msg_id="m1" + ) diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py new file mode 100644 index 000000000..85d4f5013 --- /dev/null +++ b/tests/adapters/agno/test_rehydration.py @@ -0,0 +1,261 @@ +"""Agno history/context rehydration tests. + +These drive the adapter through the real ``on_event`` path so the real +``AgnoHistoryConverter`` runs, then inspect the exact ``list[Message]`` Agno +received via the faked ``agent.arun(input=...)``. Assertions are on real Agno +``Message`` objects (roles, ``tool_calls``, ``tool_call_id``, ``from_history``), +never on hardcoded prose. History is built with the real runtime formatter +``format_history_for_llm`` rather than hand-rolled converter-ready dicts. +""" + +from __future__ import annotations + +from agno.models.message import Message +from agno.run.agent import RunOutput + +from band.runtime.formatters import format_history_for_llm +from band.testing import FakeAgentTools +from tests.framework_configs.fixtures import TOOL_CALL_SEARCH, TOOL_RESULT_SEARCH + +from .helpers import make_agent_input, platform_msg, run_input, started + + +class TestRehydrationPipeline: + """Drive on_event so the real AgnoHistoryConverter runs, then inspect the + actual run input Agno received.""" + + async def test_all_message_kinds_become_the_right_messages( + self, sample_platform_message + ): + # Authentic rehydration: build platform dicts and run them through the + # real runtime formatter (which also drops the current message). + raw = format_history_for_llm( + [ + platform_msg("h1", "Prior question", sender_name="Alice"), + platform_msg( + "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" + ), + platform_msg( + "h3", + TOOL_CALL_SEARCH["content"], + sender_type="Agent", + sender_name="TestBot", + message_type="tool_call", + ), + platform_msg( + "h4", + TOOL_RESULT_SEARCH["content"], + sender_type="Agent", + sender_name="TestBot", + message_type="tool_result", + ), + ], + exclude_id=sample_platform_message.id, + ) + adapter, copy = await started(RunOutput(content="ack")) + + await adapter.on_event( + make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) + ) + + msgs = run_input(copy) + assert [m.role for m in msgs] == [ + "user", # other participant text + "assistant", # own-agent text + "assistant", # tool_call batched onto an assistant message + "tool", # tool_result + "user", # the current (live) message + ] + assert msgs[0].content == "[Alice]: Prior question" + assert msgs[1].content == "Earlier answer" + assert msgs[2].tool_calls[0]["function"]["name"] == "search" + assert msgs[3].tool_call_id == "tc_1" + assert msgs[-1].content == sample_platform_message.format_for_llm() + + async def test_unsupported_kinds_are_dropped(self, sample_platform_message): + raw = format_history_for_llm( + [ + platform_msg("h1", "hello", sender_name="Alice"), + platform_msg( + "h2", + "thinking out loud", + sender_type="Agent", + sender_name="TestBot", + message_type="thought", + ), + platform_msg("h3", "weird", message_type="mystery"), + ], + exclude_id=sample_platform_message.id, + ) + adapter, copy = await started(RunOutput(content="ack")) + + await adapter.on_event( + make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) + ) + + msgs = run_input(copy) + # Only the plain text + current message survive; thought/unknown dropped. + assert [m.content for m in msgs] == [ + "[Alice]: hello", + sample_platform_message.format_for_llm(), + ] + + async def test_history_is_from_history_but_current_message_is_live( + self, sample_platform_message + ): + raw = format_history_for_llm( + [platform_msg("h1", "hi", sender_name="Alice")], + exclude_id=sample_platform_message.id, + ) + adapter, copy = await started(RunOutput(content="ack")) + + await adapter.on_event( + make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) + ) + + msgs = run_input(copy) + assert all(m.from_history for m in msgs[:-1]) # rehydrated context + assert not msgs[-1].from_history # the message to actually answer + + async def test_participants_and_contacts_injected_before_current_message( + self, sample_platform_message + ): + adapter, copy = await started(RunOutput(content="ok")) + + await adapter.on_event( + make_agent_input( + sample_platform_message, + [], + is_session_bootstrap=True, + participants_msg="Alice and Bob are here", + contacts_msg="Carol is now a contact", + ) + ) + + msgs = run_input(copy) + assert [m.content for m in msgs] == [ + "[System]: Alice and Bob are here", + "[System]: Carol is now a contact", + sample_platform_message.format_for_llm(), + ] + + +class TestUnansweredMessage: + async def test_current_message_excluded_from_history_then_answered( + self, sample_platform_message + ): + current = sample_platform_message + # The platform context includes the current message; the formatter must + # exclude it so it is answered, not replayed as context. + raw = format_history_for_llm( + [ + platform_msg("h1", "previous", sender_name="Alice"), + {**platform_msg(current.id, current.content), "id": current.id}, + ], + exclude_id=current.id, + ) + assert len(raw) == 1 + assert all(current.content not in h["content"] for h in raw) + + tools = FakeAgentTools() + adapter, copy = await started(RunOutput(content="here is your answer")) + + await adapter.on_event( + make_agent_input(current, raw, is_session_bootstrap=True, tools=tools) + ) + + tools.assert_message_sent( + content="here is your answer", mentions=[current.sender_id] + ) + msgs = run_input(copy) + formatted = current.format_for_llm() + assert sum(1 for m in msgs if m.content == formatted) == 1 + assert msgs[-1].content == formatted + + async def test_answers_unanswered_message_on_restart_bootstrap( + self, sample_platform_message + ): + # Agent restarts: first event is bootstrap, with a completed exchange in + # history and a brand-new unanswered question as the current message. + raw = format_history_for_llm( + [ + platform_msg("h1", "Earlier question", sender_name="Alice"), + platform_msg( + "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" + ), + ], + exclude_id=sample_platform_message.id, + ) + tools = FakeAgentTools() + adapter, copy = await started(RunOutput(content="fresh answer")) + + await adapter.on_event( + make_agent_input( + sample_platform_message, raw, is_session_bootstrap=True, tools=tools + ) + ) + + copy.arun.assert_awaited_once() + tools.assert_message_sent( + content="fresh answer", mentions=[sample_platform_message.sender_id] + ) + assert run_input(copy)[-1].content == sample_platform_message.format_for_llm() + + async def test_trailing_unanswered_user_turns_are_preserved( + self, sample_platform_message + ): + # Several user turns with no assistant reply between them: agno keeps them + # all as user messages (it does not require complete exchanges). + raw = format_history_for_llm( + [ + platform_msg("h1", "first", sender_name="Alice"), + platform_msg("h2", "second", sender_name="Bob"), + platform_msg("h3", "third", sender_name="Alice"), + ], + exclude_id=sample_platform_message.id, + ) + tools = FakeAgentTools() + adapter, copy = await started(RunOutput(content="answering all")) + + await adapter.on_event( + make_agent_input( + sample_platform_message, raw, is_session_bootstrap=True, tools=tools + ) + ) + + msgs = run_input(copy) + assert [m.role for m in msgs] == ["user", "user", "user", "user"] + assert [m.content for m in msgs[:3]] == [ + "[Alice]: first", + "[Bob]: second", + "[Alice]: third", + ] + tools.assert_message_sent(content="answering all") + + +class TestMultiTurnCarryover: + async def test_persisted_transcript_feeds_the_next_turn( + self, sample_platform_message + ): + # Turn 1's run produces a transcript; _persist_turn keeps it and the next + # turn must build on top of it (carryover through the real on_message path). + turn = RunOutput( + content="a1", + messages=[ + Message(role="user", content="[Alice]: q1"), + Message(role="assistant", content="a1"), + ], + ) + adapter, copy = await started(turn) + + await adapter.on_event( + make_agent_input(sample_platform_message, [], is_session_bootstrap=True) + ) + await adapter.on_event( + make_agent_input(sample_platform_message, [], is_session_bootstrap=False) + ) + + msgs = run_input(copy) # the second (follow-up) turn's input + assert [m.content for m in msgs[:2]] == ["[Alice]: q1", "a1"] + assert msgs[-1].content == sample_platform_message.format_for_llm() + assert len(msgs) == 3 diff --git a/tests/converters/test_agno.py b/tests/converters/test_agno.py new file mode 100644 index 000000000..4f6aa6bce --- /dev/null +++ b/tests/converters/test_agno.py @@ -0,0 +1,188 @@ +"""Agno-specific history converter tests. + +These cover behavior the framework-conformance suite cannot assert because it +only checks generic shape via an output adapter ("tool name appears somewhere", +text/own-message handling). Here we assert on the real Agno ``Message`` objects: +tool_call/tool_result structure, batching, role mapping, and the ``from_history`` +tagging that stops Agno from re-adding stored session history. +""" + +from __future__ import annotations + +import json + +from band.converters.agno import AgnoHistoryConverter +from tests.framework_configs.fixtures import ( + TOOL_CALL_LOOKUP, + TOOL_CALL_SEARCH, + TOOL_CALL_SEARCH_EMPTY, + TOOL_RESULT_SEARCH, +) + + +def _text(content: str, *, role: str = "user", sender_name: str = "") -> dict: + return { + "role": role, + "content": content, + "sender_name": sender_name, + "message_type": "text", + } + + +class TestToolCallShape: + def test_tool_call_becomes_assistant_message_with_function_dict(self): + result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) + + assert len(result) == 1 + msg = result[0] + assert msg.role == "assistant" + assert msg.content is None + assert msg.from_history is True + assert msg.tool_calls == [ + { + "id": "tc_1", + "type": "function", + "function": { + "name": "search", + "arguments": json.dumps({"query": "test"}), + }, + } + ] + + def test_arguments_are_json_string_not_dict(self): + result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) + + arguments = result[0].tool_calls[0]["function"]["arguments"] + assert isinstance(arguments, str) + assert json.loads(arguments) == {"query": "test"} + + +class TestToolResultShape: + def test_tool_result_becomes_tool_role_message(self): + result = AgnoHistoryConverter().convert( + [dict(TOOL_CALL_SEARCH), dict(TOOL_RESULT_SEARCH)] + ) + + assert len(result) == 2 + tool_msg = result[1] + assert tool_msg.role == "tool" + assert tool_msg.tool_call_id == "tc_1" + assert tool_msg.tool_name == "search" + assert tool_msg.content == "result data" + assert tool_msg.tool_call_error is False + assert tool_msg.from_history is True + + def test_error_flag_maps_to_tool_call_error(self): + errored = { + "role": "assistant", + "content": json.dumps( + { + "name": "search", + "output": "boom", + "tool_call_id": "tc_1", + "is_error": True, + } + ), + "message_type": "tool_result", + } + + result = AgnoHistoryConverter().convert([errored]) + + assert result[0].tool_call_error is True + + +class TestBatchingAndFlush: + def test_consecutive_tool_calls_batch_into_one_message(self): + result = AgnoHistoryConverter().convert( + [dict(TOOL_CALL_SEARCH), dict(TOOL_CALL_LOOKUP)] + ) + + assert len(result) == 1 + assert len(result[0].tool_calls) == 2 + assert [tc["function"]["name"] for tc in result[0].tool_calls] == [ + "search", + "lookup", + ] + + def test_text_flushes_pending_calls_before_appending(self): + result = AgnoHistoryConverter().convert( + [dict(TOOL_CALL_SEARCH), _text("done", sender_name="Alice")] + ) + + assert [m.role for m in result] == ["assistant", "user"] + assert result[0].tool_calls[0]["function"]["name"] == "search" + assert result[1].content == "[Alice]: done" + + def test_orphaned_trailing_tool_calls_are_flushed(self): + result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) + + # No matching tool_result, but the pending call still lands as a message. + assert len(result) == 1 + assert result[0].role == "assistant" + + +class TestTextRoleMapping: + def test_own_agent_text_kept_as_assistant(self): + converter = AgnoHistoryConverter(agent_name="TestBot") + + result = converter.convert( + [_text("on it", role="assistant", sender_name="TestBot")] + ) + + assert len(result) == 1 + assert result[0].role == "assistant" + assert result[0].content == "on it" + assert result[0].from_history is True + + def test_other_sender_gets_user_role_with_prefix(self): + converter = AgnoHistoryConverter(agent_name="TestBot") + + result = converter.convert([_text("hi", sender_name="Alice")]) + + assert result[0].role == "user" + assert result[0].content == "[Alice]: hi" + + def test_missing_sender_name_has_no_prefix(self): + result = AgnoHistoryConverter().convert([_text("hi")]) + + assert result[0].content == "hi" + + +class TestFromHistoryInvariant: + def test_every_message_is_tagged_from_history(self): + converter = AgnoHistoryConverter(agent_name="TestBot") + + result = converter.convert( + [ + _text("hi", sender_name="Alice"), + dict(TOOL_CALL_SEARCH), + dict(TOOL_RESULT_SEARCH), + _text("done", role="assistant", sender_name="TestBot"), + ] + ) + + assert result # sanity: not empty + assert all(m.from_history for m in result) + + +class TestMalformedAndUnknown: + def test_tool_call_missing_id_is_skipped(self): + result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH_EMPTY)]) + + # TOOL_CALL_SEARCH_EMPTY still has a tool_call_id, so it converts; a + # genuinely id-less call is dropped: + idless = { + "role": "assistant", + "content": json.dumps({"name": "search", "args": {}}), + "message_type": "tool_call", + } + assert AgnoHistoryConverter().convert([idless]) == [] + assert len(result) == 1 # empty args still produce a valid call + + def test_unknown_message_type_is_skipped(self): + thought = {"role": "assistant", "content": "hmm", "message_type": "thought"} + + assert AgnoHistoryConverter().convert([thought]) == [] + + def test_empty_history(self): + assert AgnoHistoryConverter().convert([]) == [] From 952a93949fc758a1ad0b8d1cb7dad797522aaebd Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 14:51:55 +0300 Subject: [PATCH 34/90] test(agno): extract a tools fixture into conftest Add tests/adapters/agno/conftest.py with a `tools` fixture returning a fresh FakeAgentTools(), and have the 14 tests that built one inline take it as a parameter instead. Mirrors langgraph's mock_tools fixture and trims the repeated construction line. Tests needing a specialized tool surface (SchemaTools, the inline _StrTools subclass) still build their own. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/adapters/agno/conftest.py | 16 ++++++++++ tests/adapters/agno/test_adapter.py | 39 +++++++++++-------------- tests/adapters/agno/test_rehydration.py | 10 ++----- 3 files changed, 36 insertions(+), 29 deletions(-) create mode 100644 tests/adapters/agno/conftest.py diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py new file mode 100644 index 000000000..a06f72420 --- /dev/null +++ b/tests/adapters/agno/conftest.py @@ -0,0 +1,16 @@ +"""Shared fixtures for the Agno adapter tests. + +(``sample_platform_message`` comes from the root ``tests/conftest.py``.) +""" + +from __future__ import annotations + +import pytest + +from band.testing import FakeAgentTools + + +@pytest.fixture +def tools() -> FakeAgentTools: + """A fresh, call-tracking Band tool surface for one test.""" + return FakeAgentTools() diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 255b3c0cc..62db44001 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -156,8 +156,7 @@ async def test_no_capabilities_excludes_memory_and_contacts( class TestBandEntrypointBinding: - async def test_routes_to_execute_tool_call_inside_context(self): - tools = FakeAgentTools() + async def test_routes_to_execute_tool_call_inside_context(self, tools): entry = _make_band_entrypoint("band_lookup_peers") with _bind_room_tools(tools): @@ -177,8 +176,7 @@ async def execute_tool_call(self, tool_name: str, arguments: dict) -> Any: with _bind_room_tools(_StrTools()): assert await entry() == "raw-string" - async def test_errors_outside_any_bound_context(self): - tools = FakeAgentTools() + async def test_errors_outside_any_bound_context(self, tools): entry = _make_band_entrypoint("band_lookup_peers") # Bind then exit; the ContextVar must reset so later calls have no tools. @@ -192,9 +190,8 @@ async def test_errors_outside_any_bound_context(self): class TestReply: async def test_sends_fallback_text_when_agent_did_not_post( - self, sample_platform_message + self, sample_platform_message, tools ): - tools = FakeAgentTools() adapter, _ = await started(RunOutput(content="hello")) await adapter.on_message( @@ -210,9 +207,8 @@ async def test_sends_fallback_text_when_agent_did_not_post( tools.assert_message_sent(content="hello", mentions=["user-456"]) async def test_skips_fallback_when_agent_called_band_send_message( - self, sample_platform_message + self, sample_platform_message, tools ): - tools = FakeAgentTools() response = RunOutput( content="hello", tools=[tool_execution("band_send_message")] ) @@ -230,8 +226,7 @@ async def test_skips_fallback_when_agent_called_band_send_message( tools.assert_no_messages_sent() - async def test_no_send_for_empty_content(self, sample_platform_message): - tools = FakeAgentTools() + async def test_no_send_for_empty_content(self, sample_platform_message, tools): adapter, _ = await started(RunOutput(content=" ")) await adapter.on_message( @@ -248,8 +243,9 @@ async def test_no_send_for_empty_content(self, sample_platform_message): class TestEmitExecution: - async def test_emits_tool_call_and_result_events(self, sample_platform_message): - tools = FakeAgentTools() + async def test_emits_tool_call_and_result_events( + self, sample_platform_message, tools + ): response = RunOutput( tools=[tool_execution("band_lookup_peers", args={"page": "1"}, result="ok")] ) @@ -280,9 +276,8 @@ async def test_emits_tool_call_and_result_events(self, sample_platform_message): assert result_payload["is_error"] is False async def test_self_reporting_tools_are_not_re_emitted( - self, sample_platform_message + self, sample_platform_message, tools ): - tools = FakeAgentTools() response = RunOutput(tools=[tool_execution("band_send_message")]) adapter, _ = await started( response, features=AdapterFeatures(emit={Emit.EXECUTION}) @@ -300,8 +295,9 @@ async def test_self_reporting_tools_are_not_re_emitted( assert tools.events_sent == [] - async def test_no_events_without_execution_emit(self, sample_platform_message): - tools = FakeAgentTools() + async def test_no_events_without_execution_emit( + self, sample_platform_message, tools + ): response = RunOutput(tools=[tool_execution("band_lookup_peers")]) adapter, _ = await started(response) # no emit configured @@ -319,8 +315,7 @@ async def test_no_events_without_execution_emit(self, sample_platform_message): class TestEmitThoughts: - async def test_emits_reasoning_as_thought(self, sample_platform_message): - tools = FakeAgentTools() + async def test_emits_reasoning_as_thought(self, sample_platform_message, tools): response = RunOutput(reasoning_content="thinking hard") adapter, _ = await started( response, features=AdapterFeatures(emit={Emit.THOUGHTS}) @@ -339,8 +334,9 @@ async def test_emits_reasoning_as_thought(self, sample_platform_message): tools.assert_event_sent(message_type="thought") assert tools.events_sent[0]["content"] == "thinking hard" - async def test_no_thought_without_thoughts_emit(self, sample_platform_message): - tools = FakeAgentTools() + async def test_no_thought_without_thoughts_emit( + self, sample_platform_message, tools + ): response = RunOutput(reasoning_content="thinking hard") adapter, _ = await started(response) # no emit configured @@ -356,8 +352,7 @@ async def test_no_thought_without_thoughts_emit(self, sample_platform_message): assert tools.events_sent == [] - async def test_no_thought_for_blank_reasoning(self, sample_platform_message): - tools = FakeAgentTools() + async def test_no_thought_for_blank_reasoning(self, sample_platform_message, tools): adapter, _ = await started( RunOutput(reasoning_content=" "), features=AdapterFeatures(emit={Emit.THOUGHTS}), diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py index 85d4f5013..c5ab02181 100644 --- a/tests/adapters/agno/test_rehydration.py +++ b/tests/adapters/agno/test_rehydration.py @@ -14,7 +14,6 @@ from agno.run.agent import RunOutput from band.runtime.formatters import format_history_for_llm -from band.testing import FakeAgentTools from tests.framework_configs.fixtures import TOOL_CALL_SEARCH, TOOL_RESULT_SEARCH from .helpers import make_agent_input, platform_msg, run_input, started @@ -142,7 +141,7 @@ async def test_participants_and_contacts_injected_before_current_message( class TestUnansweredMessage: async def test_current_message_excluded_from_history_then_answered( - self, sample_platform_message + self, sample_platform_message, tools ): current = sample_platform_message # The platform context includes the current message; the formatter must @@ -157,7 +156,6 @@ async def test_current_message_excluded_from_history_then_answered( assert len(raw) == 1 assert all(current.content not in h["content"] for h in raw) - tools = FakeAgentTools() adapter, copy = await started(RunOutput(content="here is your answer")) await adapter.on_event( @@ -173,7 +171,7 @@ async def test_current_message_excluded_from_history_then_answered( assert msgs[-1].content == formatted async def test_answers_unanswered_message_on_restart_bootstrap( - self, sample_platform_message + self, sample_platform_message, tools ): # Agent restarts: first event is bootstrap, with a completed exchange in # history and a brand-new unanswered question as the current message. @@ -186,7 +184,6 @@ async def test_answers_unanswered_message_on_restart_bootstrap( ], exclude_id=sample_platform_message.id, ) - tools = FakeAgentTools() adapter, copy = await started(RunOutput(content="fresh answer")) await adapter.on_event( @@ -202,7 +199,7 @@ async def test_answers_unanswered_message_on_restart_bootstrap( assert run_input(copy)[-1].content == sample_platform_message.format_for_llm() async def test_trailing_unanswered_user_turns_are_preserved( - self, sample_platform_message + self, sample_platform_message, tools ): # Several user turns with no assistant reply between them: agno keeps them # all as user messages (it does not require complete exchanges). @@ -214,7 +211,6 @@ async def test_trailing_unanswered_user_turns_are_preserved( ], exclude_id=sample_platform_message.id, ) - tools = FakeAgentTools() adapter, copy = await started(RunOutput(content="answering all")) await adapter.on_event( From 85f3489a959cf954dfc66e9dcddf5a2d6ed910bc Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 16:13:52 +0300 Subject: [PATCH 35/90] test(agno): add live multi-agent, restart, and thoughts E2E scenarios Add agno-specific E2E tests under tests/e2e/scenarios/agno/ that run real Agno agents against the live platform with a real LLM: - Scenario 1: assistant invites a calculator agent to total a grocery list, then removes it. Verifies the calculator's add_numbers tool actually ran, the total was reported, and the agent was removed -- all via direct REST. - Scenario 2: same flow with an agent killed/restarted mid-conversation (parametrized A/B/both) to verify history rehydration on bootstrap. - Scenario 3: a reasoning agent emits thought events. Findings encoded in the tests: - Agent events (thought/tool_call/tool_result) surface via the REST context endpoint, not the user WebSocket message_created stream. Tests synchronize on the agent's text reply over WS, then assert events via REST. - The platform rate-limits agent WebSocket reconnects (HTTP 429) after a recent supersede; running_agent retries the connect with tenacity, honoring the server-supplied retry_after. Supporting changes: - Second-agent fixtures (e2e_session_client_2 / e2e_agent_info_2). - Generic helpers: listening_for_room_activity, find_tool_call_in_context, and Rich-based step/banner logging for a followable transcript. - Declare rich and tenacity in dev extras; README for the agno E2E folder. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 8 + tests/e2e/conftest.py | 35 +- tests/e2e/helpers.py | 139 +++++++ tests/e2e/scenarios/agno/README.md | 78 ++++ tests/e2e/scenarios/agno/__init__.py | 0 tests/e2e/scenarios/agno/conftest.py | 392 +++++++++++++++++++ tests/e2e/scenarios/agno/test_multi_agent.py | 332 ++++++++++++++++ tests/e2e/scenarios/agno/test_thoughts.py | 105 +++++ uv.lock | 8 + 9 files changed, 1096 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/scenarios/agno/README.md create mode 100644 tests/e2e/scenarios/agno/__init__.py create mode 100644 tests/e2e/scenarios/agno/conftest.py create mode 100644 tests/e2e/scenarios/agno/test_multi_agent.py create mode 100644 tests/e2e/scenarios/agno/test_thoughts.py diff --git a/pyproject.toml b/pyproject.toml index bb3462580..f778606df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -186,6 +186,10 @@ dev = [ "python-dotenv>=1.2.2", # Include bridge_agentcore deps for testing "boto3>=1.35.0", + # Pretty E2E test output + "rich>=13.0.0", + # Retry/cooldown for rate-limited platform reconnects in E2E tests + "tenacity>=8.0.0", # Development tools "pre-commit>=3.0.0", "ruff>=0.8.0", @@ -211,6 +215,10 @@ dev-crewai = [ "openai>=2.0.0", "nest-asyncio>=1.6.0", "pillow>=12.1.1", + # Pretty E2E test output + "rich>=13.0.0", + # Retry/cooldown for rate-limited platform reconnects in E2E tests + "tenacity>=8.0.0", # Development tools "ruff>=0.8.0", "pyrefly>=0.18.0", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d1e18c26a..1179c2ae3 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -85,7 +85,8 @@ class E2ESettings(BaseTestSettings): (e.g. E2E_LLM_MODEL -> e2e_llm_model) with case-insensitive matching. """ - _env_file_path = Path(__file__).parent.parent.parent / ".env.test" + class Config: + env_file = _ENV_TEST_PATH band_api_key: str = "" band_api_key_2: str = "" @@ -385,6 +386,38 @@ async def e2e_agent_info(e2e_session_client: AsyncRestClient) -> tuple[str, str] return agent_me.data.id, agent_me.data.name +@pytest.fixture(scope="session") +def e2e_session_client_2( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client for the *second* test agent. + + Multi-agent E2E tests need a distinct agent identity (different API key) + so two agents can coexist in the same room. Skips cleanly when the second + agent is not provisioned in .env.test. + """ + if not e2e_config.band_api_key_2: + pytest.skip("BAND_API_KEY_2 not set (needed for multi-agent E2E tests)") + + return AsyncRestClient( + api_key=e2e_config.band_api_key_2, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture(scope="session") +async def e2e_agent_info_2( + e2e_session_client_2: AsyncRestClient, +) -> tuple[str, str]: + """Get (agent_id, agent_name) for the second test agent. + + Used by multi-agent tests to @mention the second agent and to verify it + was added to / removed from a room. + """ + agent_me = await e2e_session_client_2.agent_api_identity.get_agent_me() + return agent_me.data.id, agent_me.data.name + + @pytest.fixture(scope="session") async def ws_client( e2e_config: E2ESettings, diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 825a6f83d..5af84a0cc 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -7,10 +7,13 @@ from __future__ import annotations import asyncio +import json import logging from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager +from typing import Any +from rich.console import Console from band_rest import AsyncRestClient, ChatMessageRequest from band_rest.types import ( ChatMessageRequestMentionsItem as Mention, @@ -21,6 +24,26 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Pretty logging (followable transcript when running with -s) +# ============================================================================= + +# Rich renders a readable transcript under ``pytest -s`` and degrades to plain +# text when stdout is captured/non-tty. Console is the standard tool for this; +# no need to hand-roll banner formatting. +_console = Console() + + +def log_banner(title: str) -> None: + """Render a visually distinct section banner via a Rich rule.""" + _console.rule(f"[bold cyan]{title}[/]") + + +def log_step(n: int | str | float, text: str) -> None: + """Render a numbered step marker within a scenario.""" + _console.print(f" [bold green]\\[step {n}][/] {text}") + + class TrackingWebSocketClient: """Wrapper around WebSocketClient that tracks joined rooms for cleanup. @@ -171,6 +194,122 @@ async def wait() -> list[MessageCreatedPayload]: await ws_client.leave_chat_room_channel(room_id) +@asynccontextmanager +async def listening_for_room_activity( + ws_client: WebSocketClient | TrackingWebSocketClient, + room_id: str, + *, + timeout: float = 30.0, + message_types: tuple[str, ...] = ("text",), + sender_id: str | None = None, + min_messages: int = 1, + raise_on_timeout: bool = False, +) -> AsyncGenerator[Callable[[], Awaitable[list[MessageCreatedPayload]]], None]: + """Subscribe to a room and collect agent activity matching a filter. + + A generalized variant of :func:`listening_for_agent_responses` that can + capture non-text events (``thought``, ``tool_call``, ``tool_result``) and + optionally restrict to a single sender. Collects every ``message_created`` + payload from an Agent whose ``message_type`` is in *message_types* (and, + when *sender_id* is given, whose ``sender_id`` matches). + + Usage:: + + async with listening_for_room_activity( + ws, room_id, message_types=("thought",) + ) as wait: + await send_trigger_message(client, room_id, "Think it through", ...) + thoughts = await wait() + + Args: + ws_client: Connected WebSocket client (or TrackingWebSocketClient). + room_id: Chat room to listen on. + timeout: Maximum seconds ``wait()`` will block. + message_types: Message types to collect (default text only). + sender_id: If set, only collect activity from this sender. + min_messages: Minimum matching messages before ``wait()`` returns. + raise_on_timeout: If True, ``wait()`` raises ``TimeoutError`` instead + of returning partial results. + + Yields: + An async callable that blocks until *min_messages* matching messages + arrive (or *timeout* elapses) and returns the collected payloads. + """ + received: list[MessageCreatedPayload] = [] + event = asyncio.Event() + + async def handler(payload: MessageCreatedPayload) -> None: + if payload.sender_type != "Agent" or payload.message_type not in message_types: + return + if sender_id is not None and payload.sender_id != sender_id: + return + received.append(payload) + logger.info( + "Received %s from %s in room %s: %s", + payload.message_type, + payload.sender_name or payload.sender_id, + room_id, + payload.content[:80], + ) + if len(received) >= min_messages: + event.set() + + await ws_client.join_chat_room_channel(room_id, handler) + try: + + async def wait() -> list[MessageCreatedPayload]: + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except TimeoutError: + logger.warning( + "Timeout waiting for %s in room %s (received %d/%d after %.1fs)", + message_types, + room_id, + len(received), + min_messages, + timeout, + ) + if raise_on_timeout: + raise + return received + + yield wait + finally: + await ws_client.leave_chat_room_channel(room_id) + + +def find_tool_call_in_context(items: list[Any], tool_name: str) -> bool: + """Return True if any context item is a ``tool_call`` event for *tool_name*. + + The Agno adapter posts tool executions as ``tool_call`` events whose + ``content`` is a JSON object ``{"name": ..., "args": ..., ...}`` (see + ``AgnoAdapter._emit_execution``). This parses those payloads and matches + on the tool name, falling back to a substring check if the content is not + valid JSON. + + Args: + items: Context items from ``fetch_all_context`` (each has + ``message_type`` and ``content`` attributes). + tool_name: The tool name to look for (e.g. ``"add_numbers"``). + """ + matches = ( + _tool_call_name_matches(item, tool_name) + for item in items + if getattr(item, "message_type", None) == "tool_call" + ) + return any(matches) + + +def _tool_call_name_matches(item: Any, tool_name: str) -> bool: + """Check a single ``tool_call`` context item against *tool_name*.""" + content = getattr(item, "content", "") or "" + try: + parsed = json.loads(content) + except (json.JSONDecodeError, TypeError): + return tool_name in content + return isinstance(parsed, dict) and parsed.get("name") == tool_name + + def assert_content_contains( messages: list[MessageCreatedPayload], expected_substring: str, diff --git a/tests/e2e/scenarios/agno/README.md b/tests/e2e/scenarios/agno/README.md new file mode 100644 index 000000000..c131e2652 --- /dev/null +++ b/tests/e2e/scenarios/agno/README.md @@ -0,0 +1,78 @@ +# Agno adapter — E2E scenarios + +Live, multi-agent E2E tests for the Agno adapter. They run real Agno agents +against a real Band platform with a real LLM and assert on platform state via +**direct REST queries** (not just WebSocket observation). + +> The generic smoke / tool-execution coverage for Agno already runs via the +> parametrized suite in `tests/e2e/adapters/test_all_adapters.py`. This folder +> covers behavior that suite can't: multi-agent orchestration, history +> rehydration across restarts, and reasoning-as-thoughts emission. + +## What each test verifies + +| Test | Flow | Key assertion | +|------|------|---------------| +| `test_multi_agent.py::…invites_calculator_for_total` | Assistant (A) chats about a grocery list, invites a calculator agent (B), asks for the total, then removes B. | B's `add_numbers` tool actually ran (REST), total reported, B removed. | +| `test_multi_agent.py::…survives_restart[A/B/both]` | Same flow, but an agent is killed and restarted mid-conversation. | The restarted agent rehydrates history (`is_session_bootstrap`) and continues; tool runs again. | +| `test_thoughts.py::…emits_thought_events` | A single `reasoning=True` agent answers a step-by-step question. | A `thought` event is emitted (verified via REST). | + +## Cast + +- **Agent A — assistant** (`build_assistant_adapter`): no tools of its own, but + gets Band's chat/participant tools by default. Orchestrates B. +- **Agent B — calculator** (`create_calculator_agno_adapter`): owns a native + `add_numbers` tool; `Emit.EXECUTION` posts its `tool_call`/`tool_result` so + the run is observable. +- **User**: sends the trigger messages and observes via WebSocket. + +## Prerequisites + +Set in `.env.test` (tests `skip` cleanly if missing): + +- `BAND_API_KEY`, `TEST_AGENT_ID` — agent A +- `BAND_API_KEY_2`, `TEST_AGENT_ID_2` — agent B (must be discoverable by A) +- `BAND_API_KEY_USER` — the user/observer +- `ANTHROPIC_API_KEY` — the LLM +- `E2E_TESTS_ENABLED=true` + +## Run + +```bash +# Whole folder (use --log-cli-level=INFO to watch the transcript live) +E2E_TESTS_ENABLED=true uv run pytest tests/e2e/scenarios/agno/ -v -s --no-cov --log-cli-level=INFO + +# One restart variant +E2E_TESTS_ENABLED=true uv run pytest \ + "tests/e2e/scenarios/agno/test_multi_agent.py::TestAgnoMultiAgent::test_multi_agent_survives_restart[A]" \ + -v -s --no-cov --log-cli-level=INFO +``` + +`-s` is required to see the Rich step transcript; `--log-cli-level=INFO` +streams the per-message logs (only shown on failure otherwise). + +## Findings baked into these tests + +- **Events are observed via REST, not WebSocket.** Agent-emitted events + (`thought`, `tool_call`, `tool_result`) are returned by `agent_api_context` + but are **not** delivered over the user's WebSocket `message_created` stream + (that carries only `text`). So the tests **synchronize on the agent's `text` + reply over WS, then assert events via REST** (`fetch_all_context`). +- **WebSocket reconnect is rate-limited (HTTP 429).** Restart scenarios stop and + start the same agent rapidly, which the platform throttles "after a recent + supersede." `running_agent` retries the connect with tenacity, honoring the + server-supplied `retry_after`. Running the whole folder in one shot may pause + for these cooldowns. +- **`reasoning=True` is fragile with Band tools.** Anthropic's stricter + reasoning-mode tool validation can reject a Band tool schema (an `integer` + with `maximum`), emptying the reasoning step. The agent still answers; the + thought is asserted via REST. Tests carry `@flaky(reruns=2)` for LLM + nondeterminism. + +## Layout + +- `conftest.py` — adapter builders, grocery fixture data, REST assertion + helpers, the `running_agent` lifecycle (with tenacity reconnect retry), and + room fixtures. +- `test_multi_agent.py` — Scenarios 1 & 2. +- `test_thoughts.py` — Scenario 3. diff --git a/tests/e2e/scenarios/agno/__init__.py b/tests/e2e/scenarios/agno/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py new file mode 100644 index 000000000..ad66c861d --- /dev/null +++ b/tests/e2e/scenarios/agno/conftest.py @@ -0,0 +1,392 @@ +"""Shared fixtures and helpers for Agno E2E scenarios. + +Agno-specific building blocks live here so the scenario test modules stay +focused on the flow being verified: + +- adapter builders (``create_calculator_agno_adapter``, ``build_assistant_adapter``, + ``build_thinking_adapter``) +- the grocery-list fixture data used by the multi-agent scenarios +- direct-REST assertion helpers (tool execution, reported total, participant + presence) and the ``running_agent`` lifecycle context manager +- dedicated room fixtures + +Generic, framework-agnostic E2E utilities (WebSocket listeners, trigger +messages, pretty logging, the second-agent fixtures) remain in +``tests/e2e/helpers.py`` and ``tests/e2e/conftest.py``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +import pytest +from band_rest import AsyncRestClient +from tenacity import ( + RetryCallState, + retry, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from band.agent import Agent +from band.client.streaming.errors import WebSocketUpgradeError +from band.core.simple_adapter import SimpleAdapter + +from tests.conftest_integration import fetch_all_context +from tests.e2e.adapters.conftest import _require_anthropic_key +from tests.e2e.conftest import E2ESettings, RoomAllocator +from tests.e2e.helpers import find_tool_call_in_context, log_step + +logger = logging.getLogger(__name__) + +# The platform rate-limits how often a single agent may (re)open its WebSocket +# "after a recent supersede" (HTTP 429). The restart scenarios deliberately +# stop/start the same agent repeatedly, so back-to-back runs can trip this. +# Retry the connect with tenacity, honoring the server-supplied retry-after. +_RETRYABLE_WS_STATUS = frozenset({429, 503}) +_WS_CONNECT_ATTEMPTS = 6 + + +def _is_rate_limited_ws_error(exc: BaseException) -> bool: + return ( + isinstance(exc, WebSocketUpgradeError) + and exc.status_code in _RETRYABLE_WS_STATUS + ) + + +def _ws_retry_wait(retry_state: RetryCallState) -> float: + """Wait the server-supplied ``retry_after`` if present, else back off.""" + exc = retry_state.outcome.exception() if retry_state.outcome else None + if isinstance(exc, WebSocketUpgradeError) and exc.retry_after: + return float(exc.retry_after) + return wait_exponential(multiplier=2, min=2, max=30)(retry_state) + + +def _log_ws_retry(retry_state: RetryCallState) -> None: + exc = retry_state.outcome.exception() if retry_state.outcome else None + status = getattr(exc, "status_code", "?") + log_step( + "retry", + f"WebSocket rate-limited (HTTP {status}); cooling down before " + f"attempt {retry_state.attempt_number + 1}", + ) + + +CALCULATOR_TOOL = "add_numbers" + +# Grocery prices chosen to sum cleanly in float (no rounding surprises) to a +# distinctive total. Keep representations the LLM is likely to echo. +GROCERY_ITEMS: list[tuple[str, float]] = [ + ("Milk", 3.50), + ("Bread", 2.50), + ("Eggs", 5.00), + ("Coffee", 12.00), + ("Cheese", 7.50), +] +GROCERY_TOTAL = sum(price for _, price in GROCERY_ITEMS) # 30.50 +# Accept both "30.5" and "30.50" formatting from the model. +TOTAL_STRINGS = ("30.50", "30.5") + + +def grocery_list_text() -> str: + """Render the grocery list with prices as a single user-facing line.""" + return ", ".join(f"{name} ${price:.2f}" for name, price in GROCERY_ITEMS) + + +# ============================================================================= +# Adapter builders +# ============================================================================= + + +def add_numbers(numbers: list[float]) -> float: + """Add a list of numbers and return the total. + + Native Agno tool used by the calculator agent in the multi-agent scenarios. + """ + total = sum(numbers) + logger.info("Calculator tool add_numbers(%s) -> %s", numbers, total) + return total + + +def create_calculator_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: + """Create an Agno "calculator" adapter that reports tool executions. + + The agent owns a native ``add_numbers`` tool; ``Emit.EXECUTION`` makes the + adapter post ``tool_call``/``tool_result`` events to the room so a test can + verify (via direct REST query) that the tool actually ran. + """ + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + from band.core.types import AdapterFeatures, Emit + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions=( + "You are a calculator agent. When asked to add up numbers, you MUST " + "use the add_numbers tool to compute the total -- never do the " + "arithmetic yourself. Reply with the total using the band_send_message " + "tool. Keep responses short." + ), + tools=[add_numbers], + ) + return AgnoAdapter( + agno_agent, + features=AdapterFeatures(emit={Emit.EXECUTION}), + ) + + +def build_assistant_adapter( + settings: E2ESettings, + *, + calculator_id: str, + calculator_name: str, +) -> SimpleAdapter[Any]: + """Build the "helpful assistant" Agno adapter (Agent A). + + The assistant has no tools of its own but receives Band's chat/participant + tools by default. Its instructions direct it to bring in the calculator + agent, ask it for the total, relay the answer, and remove it. + """ + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + + instructions = ( + "You are a helpful shopping assistant chatting with a user about their " + "grocery list. You are TERRIBLE at arithmetic and must NEVER add numbers " + "yourself. There is a calculator agent you can bring into the room:\n" + f" - name: {calculator_name}\n" + f" - id: {calculator_id}\n" + "When the user asks for the total cost, do ALL of the following, in order:\n" + f" 1. Call band_add_participant with identifier '{calculator_id}' to add " + "the calculator agent to this room.\n" + " 2. Call band_send_message with a message that @mentions the calculator " + f"(mention id {calculator_id}, name {calculator_name}), listing every item " + "and its price and asking it to add the prices up.\n" + " 3. When the calculator replies with the total, call band_send_message to " + "tell the user the total (mention the user).\n" + f" 4. Finally, call band_remove_participant with identifier " + f"'{calculator_id}' to remove the calculator agent from the room.\n" + "Keep every message short." + ) + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions=instructions, + ) + return AgnoAdapter(agno_agent) + + +def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: + """Build an Agno adapter with reasoning enabled and thought reporting on. + + ``reasoning=True`` makes the Agno agent populate ``reasoning_content`` on + the run output; ``Emit.THOUGHTS`` makes the adapter post that reasoning as + a ``thought`` event to the room. + """ + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + from band.core.types import AdapterFeatures, Emit + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions=( + "You are a careful assistant. Think through problems step by step " + "before answering. Keep your final answer short." + ), + reasoning=True, + ) + return AgnoAdapter( + agno_agent, + features=AdapterFeatures(emit={Emit.THOUGHTS}), + ) + + +# ============================================================================= +# Lifecycle + assertion helpers +# ============================================================================= + + +@retry( + retry=retry_if_exception(_is_rate_limited_ws_error), + wait=_ws_retry_wait, + stop=stop_after_attempt(_WS_CONNECT_ATTEMPTS), + before_sleep=_log_ws_retry, + reraise=True, +) +async def _start_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> Agent: + """Create and start an agent, retrying when the connect is rate-limited. + + A fresh ``Agent`` is built per attempt and a partial start is torn down + before tenacity retries, so a 429 leaves no half-connected agent behind. + """ + agent = Agent.create( + adapter=adapter, + agent_id=agent_id, + api_key=api_key, + ws_url=config.band_ws_url, + rest_url=config.band_base_url, + ) + try: + await agent.start() + except Exception: + with contextlib.suppress(Exception): + await agent.stop() + raise + return agent + + +@asynccontextmanager +async def running_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> AsyncGenerator[Agent, None]: + """Run an agent for the duration of the ``async with`` block. + + Wraps :func:`_start_agent` (which carries the tenacity retry) so callers + get clean start/stop bracketing. + """ + agent = await _start_agent( + adapter, agent_id=agent_id, api_key=api_key, config=config + ) + try: + yield agent + finally: + await agent.stop() + + +async def wait_participant_absent( + client: AsyncRestClient, + room_id: str, + participant_id: str, + *, + timeout: float = 30.0, + poll_interval: float = 3.0, +) -> bool: + """Poll the participant list until *participant_id* is gone or timeout.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + resp = await client.agent_api_participants.list_agent_chat_participants(room_id) + ids = [p.id for p in (resp.data or [])] + if participant_id not in ids: + return True + await asyncio.sleep(poll_interval) + return False + + +async def participant_present( + client: AsyncRestClient, + room_id: str, + participant_id: str, +) -> bool: + """Return True if *participant_id* is currently a room participant.""" + resp = await client.agent_api_participants.list_agent_chat_participants(room_id) + return participant_id in [p.id for p in (resp.data or [])] + + +async def assert_calculator_ran( + calculator_client: AsyncRestClient, + room_id: str, +) -> None: + """Assert (via direct REST query) the calculator's tool actually executed. + + Queries with the calculator's own client so its emitted ``tool_call`` + events are visible, then checks for an ``add_numbers`` execution. + """ + items = await fetch_all_context(calculator_client, room_id) + used = find_tool_call_in_context(items, CALCULATOR_TOOL) + assert used, ( + f"Expected a '{CALCULATOR_TOOL}' tool_call event in room {room_id}, " + f"but found none in {len(items)} context item(s). The calculator agent " + "did not run its tool." + ) + log_step("assert", f"calculator tool '{CALCULATOR_TOOL}' executed ✔") + + +async def assert_thought_emitted( + client: AsyncRestClient, + room_id: str, +) -> list[Any]: + """Assert (via direct REST query) at least one ``thought`` event exists. + + Agent-emitted events (``thought``, ``tool_call``, ``tool_result``) are + surfaced by the ``agent_api_context`` endpoint but are NOT delivered over + the user's WebSocket ``message_created`` stream (which carries only + ``text``). Always assert events via REST, not the socket. + """ + items = await fetch_all_context(client, room_id) + thoughts = [ + item for item in items if getattr(item, "message_type", None) == "thought" + ] + assert thoughts, ( + f"Expected a 'thought' event in room {room_id} context, but found none " + f"among {len(items)} item(s). The reasoning agent did not emit a thought." + ) + log_step("assert", f"{len(thoughts)} thought event(s) present via REST ✔") + return thoughts + + +async def assert_total_reported( + user_client: AsyncRestClient, + room_id: str, +) -> None: + """Assert (via direct REST query) the total appears in a room message.""" + items = await fetch_all_context(user_client, room_id) + texts = [ + getattr(item, "content", "") or "" + for item in items + if getattr(item, "message_type", None) == "text" + ] + found = any(any(t in text for t in TOTAL_STRINGS) for text in texts) + assert found, ( + f"Expected the total ({GROCERY_TOTAL:.2f}) to appear in a room message, " + f"but it was not found among {len(texts)} text message(s)." + ) + log_step("assert", f"total {GROCERY_TOTAL:.2f} reported in room ✔") + + +# ============================================================================= +# Room fixtures +# ============================================================================= + + +@pytest.fixture +async def agno_multi_room( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for the multi-agent Agno scenarios. + + Returns (room_id, user_id, user_name). The room starts with Agent A (its + creator) and the User; Agent B is added during the flow. + """ + return await e2e_room_allocator("agno_multi_agent") + + +@pytest.fixture +async def agno_thoughts_room( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for the Agno thoughts scenario.""" + return await e2e_room_allocator("agno_thoughts") diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py new file mode 100644 index 000000000..0ae4f0b68 --- /dev/null +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -0,0 +1,332 @@ +"""E2E tests for multi-agent orchestration with the Agno adapter. + +Two real Agno agents and a user collaborate against the live Band platform: + +- **Agent A** (assistant): chats with the user about a grocery list. It cannot + do arithmetic, so it invites a calculator agent, asks it for the total, and + removes it when done. +- **Agent B** (calculator): owns a native ``add_numbers`` tool and reports its + executions via ``Emit.EXECUTION`` so the test can verify (by direct REST + query) that the tool actually ran. + +Scenario 1 (``test_assistant_invites_calculator_for_total``) runs the flow +straight through. Scenario 2 (``test_multi_agent_survives_restart``) kills and +restarts an agent mid-conversation to verify history rehydration, parametrized +over which agent restarts: A, B, or both. + +Requires a second provisioned agent (``BAND_API_KEY_2`` / ``TEST_AGENT_ID_2``) +that is discoverable by the first. Tests skip cleanly when it is absent. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest \ + tests/e2e/scenarios/agno/test_multi_agent.py -v -s --no-cov +""" + +from __future__ import annotations + +import logging +import uuid + +import pytest +from band_rest import AsyncRestClient + +from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + listening_for_room_activity, + log_banner, + log_step, + send_trigger_message, +) +from tests.e2e.scenarios.agno.conftest import ( + GROCERY_TOTAL, + assert_calculator_ran, + assert_total_reported, + build_assistant_adapter, + create_calculator_agno_adapter, + grocery_list_text, + participant_present, + running_agent, + wait_participant_absent, +) + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@requires_e2e +class TestAgnoMultiAgent: + """Multi-agent orchestration and rehydration tests for the Agno adapter.""" + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + async def test_assistant_invites_calculator_for_total( + self, + e2e_config: E2ESettings, + agno_multi_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_agent_info_2: tuple[str, str], + e2e_session_client: AsyncRestClient, + e2e_session_client_2: AsyncRestClient, + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + """Assistant brings in a calculator agent to total a grocery list. + + Verifies (by direct REST query) that the calculator's tool ran, the + total was reported, and the calculator was removed afterward. + """ + room_id, _user_id, _user_name = agno_multi_room + agent_a_id, agent_a_name = e2e_agent_info + agent_b_id, agent_b_name = e2e_agent_info_2 + run_id = uuid.uuid4().hex[:6] + # Long wait: A must invite B, B must run + reply, A must relay + remove. + flow_timeout = min(float(e2e_config.e2e_timeout) * 3, 100.0) + + log_banner(f"Scenario 1: assistant invites calculator (run {run_id})") + log_step( + 1, f"cast: {agent_a_name} (assistant) + {agent_b_name} (calculator) + user" + ) + + assistant = build_assistant_adapter( + e2e_config, + calculator_id=agent_b_id, + calculator_name=agent_b_name, + ) + calculator = create_calculator_agno_adapter(e2e_config) + + async with ( + running_agent( + assistant, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ), + running_agent( + calculator, + agent_id=e2e_config.test_agent_id_2, + api_key=e2e_config.band_api_key_2, + config=e2e_config, + ), + ): + log_step( + 2, + f"user → {agent_a_name}: grocery list [{grocery_list_text()}], " + f"asks for total (expect ${GROCERY_TOTAL:.2f})", + ) + prompt = ( + f"(run {run_id}) Here is my grocery list with prices: " + f"{grocery_list_text()}. What's the total? You can't do math " + "yourself, so bring in the calculator agent to add it up, then " + "remove them once you have the answer." + ) + # Wait until the calculator posts its total (a text message from B). + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_b_id, + timeout=flow_timeout, + ) as wait_for_calculator: + await send_trigger_message( + api_client, room_id, prompt, agent_a_name, agent_a_id + ) + calc_messages = await wait_for_calculator() + + log_step( + 3, + f"{agent_b_name} replied ({len(calc_messages)} msg); verifying via REST", + ) + # Primary: the calculator's add_numbers tool actually ran. + await assert_calculator_ran(e2e_session_client_2, room_id) + # Secondary: the total reached the room. + await assert_total_reported(e2e_session_client, room_id) + + log_step(4, f"checking {agent_a_name} removed {agent_b_name}") + removed = await wait_participant_absent( + e2e_session_client, room_id, agent_b_id, timeout=flow_timeout / 2 + ) + assert removed, ( + f"Calculator agent {agent_b_name} ({agent_b_id}) was still a " + f"participant of room {room_id} after the flow completed; the " + "assistant did not remove it." + ) + log_step("assert", "calculator removed from room ✔") + + log_banner(f"Scenario 1 PASSED (run {run_id})") + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + @pytest.mark.parametrize("restart_target", ["A", "B", "both"]) + async def test_multi_agent_survives_restart( + self, + restart_target: str, + e2e_config: E2ESettings, + agno_multi_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_agent_info_2: tuple[str, str], + e2e_session_client: AsyncRestClient, + e2e_session_client_2: AsyncRestClient, + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + """Same flow with an agent killed and restarted mid-conversation. + + The restarted agent must rehydrate prior conversation from platform + history (``is_session_bootstrap``) and continue correctly. + + - target ``A``: restart the assistant after it has the grocery list, + before it computes the total. A must recall the list post-restart. + - target ``B``: restart the calculator after it has joined and summed + once, then have it recompute. B must rehydrate the conversation. + - target ``both``: restart A (then continue) and later B. + """ + room_id, _user_id, _user_name = agno_multi_room + agent_a_id, agent_a_name = e2e_agent_info + agent_b_id, agent_b_name = e2e_agent_info_2 + run_id = uuid.uuid4().hex[:6] + turn_timeout = min(float(e2e_config.e2e_timeout) * 3, 100.0) + + log_banner(f"Scenario 2: restart={restart_target} (run {run_id})") + + def build_assistant(): + return build_assistant_adapter( + e2e_config, + calculator_id=agent_b_id, + calculator_name=agent_b_name, + ) + + # --- Turn 1: establish the grocery list with the assistant only --- + log_step( + 1, + f"turn 1 — user → {agent_a_name}: grocery list " + f"[{grocery_list_text()}] (no total yet)", + ) + async with running_agent( + build_assistant(), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_a_id, + timeout=turn_timeout, + raise_on_timeout=True, + ) as wait_a: + await send_trigger_message( + api_client, + room_id, + ( + f"(run {run_id}) Here is my grocery list with prices: " + f"{grocery_list_text()}. Just confirm you've noted it — " + "do NOT total it yet and do NOT bring in anyone else." + ), + agent_a_name, + agent_a_id, + ) + await wait_a() + + # Turn 1's agent has stopped (context exited). For an A/both restart the + # fresh instance below must rehydrate the list purely from platform + # history; for a B restart it's effectively the same first start. + if restart_target in ("A", "both"): + log_step("restart", f"{agent_a_name} (assistant) killed → restarting") + + # --- Turn 2: ask for the total; assistant brings in the calculator --- + log_step( + 2, + f"turn 2 — user → {agent_a_name}: total please; " + f"{agent_a_name} invites {agent_b_name}", + ) + async with ( + running_agent( + build_assistant(), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ), + running_agent( + create_calculator_agno_adapter(e2e_config), + agent_id=e2e_config.test_agent_id_2, + api_key=e2e_config.band_api_key_2, + config=e2e_config, + ), + ): + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_b_id, + timeout=turn_timeout, + ) as wait_b: + await send_trigger_message( + api_client, + room_id, + ( + "What's the total of my grocery list? Bring in the " + "calculator agent to add up the prices I gave you." + ), + agent_a_name, + agent_a_id, + ) + await wait_b() + + log_step(3, f"verifying {agent_b_name} ran add_numbers + total via REST") + await assert_calculator_ran(e2e_session_client_2, room_id) + await assert_total_reported(e2e_session_client, room_id) + + if restart_target in ("B", "both"): + # B stays a participant; restart only its process below. + assert await participant_present( + e2e_session_client, room_id, agent_b_id + ), "Calculator should be a participant before its restart" + + # --- Turn 3 (B / both): restart the calculator, then recompute --- + if restart_target in ("B", "both"): + log_step("restart", f"{agent_b_name} (calculator) killed → restarting") + log_step( + 4, + f"turn 3 — user → {agent_a_name}: ask {agent_b_name} to recompute", + ) + async with ( + running_agent( + build_assistant(), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ), + running_agent( + create_calculator_agno_adapter(e2e_config), + agent_id=e2e_config.test_agent_id_2, + api_key=e2e_config.band_api_key_2, + config=e2e_config, + ), + ): + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_b_id, + timeout=turn_timeout, + ) as wait_b2: + await send_trigger_message( + api_client, + room_id, + ( + "Please ask the calculator agent to add up my " + "grocery prices once more and report the total." + ), + agent_a_name, + agent_a_id, + ) + await wait_b2() + + log_step(5, f"verifying restarted {agent_b_name} recomputed via REST") + # A fresh add_numbers tool_call proves B rehydrated and re-ran. + await assert_calculator_ran(e2e_session_client_2, room_id) + await assert_total_reported(e2e_session_client, room_id) + + log_banner(f"Scenario 2 PASSED restart={restart_target} (run {run_id})") diff --git a/tests/e2e/scenarios/agno/test_thoughts.py b/tests/e2e/scenarios/agno/test_thoughts.py new file mode 100644 index 000000000..ec135981f --- /dev/null +++ b/tests/e2e/scenarios/agno/test_thoughts.py @@ -0,0 +1,105 @@ +"""Agno thought-emission E2E test against the live Band platform. + +The generic smoke and tool-execution tests run for Agno via the parametrized +suite in ``adapters/test_all_adapters.py``. This module covers behavior unique +to the Agno adapter: emitting agent reasoning as ``thought`` events when +``Emit.THOUGHTS`` is enabled. + +Observability note (verified against the live platform): agent-emitted events +(``thought``, ``tool_call``, ``tool_result``) are returned by the +``agent_api_context`` REST endpoint but are NOT delivered over the user's +WebSocket ``message_created`` stream, which carries only ``text``. So this test +synchronizes on the agent's ``text`` reply over the socket, then asserts the +``thought`` event via a direct REST query. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest \ + tests/e2e/scenarios/agno/test_thoughts.py -v -s --no-cov --log-cli-level=INFO +""" + +from __future__ import annotations + +import logging + +import pytest +from band_rest import AsyncRestClient + +from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + listening_for_room_activity, + log_banner, + log_step, + send_trigger_message, +) +from tests.e2e.scenarios.agno.conftest import ( + assert_thought_emitted, + build_thinking_adapter, + running_agent, +) + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@requires_e2e +class TestAgnoThoughts: + """Verify the Agno adapter emits reasoning as thought events.""" + + @pytest.mark.flaky(reruns=2) + async def test_agent_emits_thought_events( + self, + e2e_config: E2ESettings, + agno_thoughts_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + """A reasoning Agno agent posts at least one ``thought`` event. + + Synchronizes on the agent's text reply over WebSocket (the reliable + "turn finished" signal), then asserts the thought event via REST. + """ + room_id, _user_id, _user_name = agno_thoughts_room + agent_id, agent_name = e2e_agent_info + timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) + + log_banner("Scenario 3: Agno thought emission") + log_step(1, f"starting reasoning agent {agent_name}") + + adapter = build_thinking_adapter(e2e_config) + + async with running_agent( + adapter, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + log_step(2, "asking a question that requires step-by-step reasoning") + # Wait for the agent's text reply (events don't arrive over WS). + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_for_reply: + await send_trigger_message( + api_client, + room_id, + ( + "If a basket has 3 apples and I add 2 more bags with 4 " + "apples each, how many apples are there in total? Think " + "it through step by step, then give the number." + ), + agent_name, + agent_id, + ) + await wait_for_reply() + + log_step(3, "agent replied; verifying a thought event via REST") + await assert_thought_emitted(e2e_session_client, room_id) + + log_banner("Scenario 3 PASSED") diff --git a/uv.lock b/uv.lock index 743cc6120..07e639c87 100644 --- a/uv.lock +++ b/uv.lock @@ -603,9 +603,11 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-timeout" }, { name = "python-dotenv" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, { name = "ruff" }, { name = "slack-sdk" }, { name = "starlette" }, + { name = "tenacity" }, { name = "thenvoi-testing-python" }, { name = "uvicorn" }, { name = "werkzeug" }, @@ -624,7 +626,9 @@ dev-crewai = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-timeout" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" } }, { name = "ruff" }, + { name = "tenacity" }, { name = "thenvoi-testing-python" }, ] gemini = [ @@ -764,6 +768,8 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.2.2" }, { name = "python-multipart", marker = "extra == 'a2a-gateway'", specifier = ">=0.0.22" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", marker = "extra == 'dev'", specifier = ">=13.0.0" }, + { name = "rich", marker = "extra == 'dev-crewai'", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev-crewai'", specifier = ">=0.8.0" }, { name = "slack-sdk", marker = "extra == 'dev'", specifier = ">=3.27.0" }, @@ -773,6 +779,8 @@ requires-dist = [ { name = "starlette", marker = "extra == 'acp'", specifier = ">=0.40.0" }, { name = "starlette", marker = "extra == 'dev'", specifier = ">=0.40.0" }, { name = "starlette", marker = "extra == 'slack'", specifier = ">=0.40.0" }, + { name = "tenacity", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "tenacity", marker = "extra == 'dev-crewai'", specifier = ">=8.0.0" }, { name = "thenvoi-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "thenvoi-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, { name = "uvicorn", marker = "extra == 'a2a-gateway'", specifier = ">=0.32.0" }, From 38d4678f4ffc7c05c0829ccba8a50c307d16f33b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 17 Jun 2026 16:17:20 +0300 Subject: [PATCH 36/90] test(agno): style E2E transcript logs and fix markup injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Boxed Rich banners (green on PASS), color/icon-coded steps (numbered ▶, restart ⟳, retry ↻, assert ✔). - Render dynamic text via rich.text.Text so values like "[Milk $3.50]" are not parsed as Rich markup. - Drop now-redundant trailing ✔ from assert messages (the icon supplies it). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/helpers.py | 38 ++++++++++++++++---- tests/e2e/scenarios/agno/conftest.py | 6 ++-- tests/e2e/scenarios/agno/test_multi_agent.py | 2 +- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 5af84a0cc..90511a57b 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -14,6 +14,8 @@ from typing import Any from rich.console import Console +from rich.panel import Panel +from rich.text import Text from band_rest import AsyncRestClient, ChatMessageRequest from band_rest.types import ( ChatMessageRequestMentionsItem as Mention, @@ -29,19 +31,43 @@ # ============================================================================= # Rich renders a readable transcript under ``pytest -s`` and degrades to plain -# text when stdout is captured/non-tty. Console is the standard tool for this; -# no need to hand-roll banner formatting. +# text when stdout is captured/non-tty. All dynamic text is passed through +# ``rich.text.Text`` (no markup parsing) so values like "[Milk $3.50]" can't +# be misread as style tags. _console = Console() +# Style + icon per step kind. Numeric/other steps fall back to the default. +_STEP_KINDS: dict[str, tuple[str, str]] = { + "assert": ("bold green", "✔"), + "restart": ("bold yellow", "⟳"), + "retry": ("bold dark_orange", "↻"), +} +_STEP_DEFAULT: tuple[str, str] = ("bold cyan", "▶") + def log_banner(title: str) -> None: - """Render a visually distinct section banner via a Rich rule.""" - _console.rule(f"[bold cyan]{title}[/]") + """Render a boxed section banner; green when it announces a pass.""" + passed = "PASS" in title.upper() + _console.print() + _console.print( + Panel( + Text(title, style="bold green" if passed else "bold bright_white"), + border_style="green" if passed else "bright_cyan", + padding=(0, 2), + expand=True, + ) + ) def log_step(n: int | str | float, text: str) -> None: - """Render a numbered step marker within a scenario.""" - _console.print(f" [bold green]\\[step {n}][/] {text}") + """Render a color/icon-coded step marker within a scenario.""" + style, icon = _STEP_KINDS.get(str(n), _STEP_DEFAULT) + label = str(n) if str(n) in _STEP_KINDS else f"step {n}" + line = Text(" ") + line.append(f"{icon} {label}", style=style) + line.append(" ") + line.append(text, style="white") + _console.print(line) class TrackingWebSocketClient: diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index ad66c861d..6e16c1619 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -322,7 +322,7 @@ async def assert_calculator_ran( f"but found none in {len(items)} context item(s). The calculator agent " "did not run its tool." ) - log_step("assert", f"calculator tool '{CALCULATOR_TOOL}' executed ✔") + log_step("assert", f"calculator tool '{CALCULATOR_TOOL}' executed") async def assert_thought_emitted( @@ -344,7 +344,7 @@ async def assert_thought_emitted( f"Expected a 'thought' event in room {room_id} context, but found none " f"among {len(items)} item(s). The reasoning agent did not emit a thought." ) - log_step("assert", f"{len(thoughts)} thought event(s) present via REST ✔") + log_step("assert", f"{len(thoughts)} thought event(s) present via REST") return thoughts @@ -364,7 +364,7 @@ async def assert_total_reported( f"Expected the total ({GROCERY_TOTAL:.2f}) to appear in a room message, " f"but it was not found among {len(texts)} text message(s)." ) - log_step("assert", f"total {GROCERY_TOTAL:.2f} reported in room ✔") + log_step("assert", f"total {GROCERY_TOTAL:.2f} reported in room") # ============================================================================= diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py index 0ae4f0b68..3db547538 100644 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -151,7 +151,7 @@ async def test_assistant_invites_calculator_for_total( f"participant of room {room_id} after the flow completed; the " "assistant did not remove it." ) - log_step("assert", "calculator removed from room ✔") + log_step("assert", "calculator removed from room") log_banner(f"Scenario 1 PASSED (run {run_id})") From cb278135091d98428273a3df53882030a6672d55 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 09:10:01 +0300 Subject: [PATCH 37/90] feat(agno): inject Band tool guidance into the agent system prompt Wire the Band tool instructions alongside the auto-wired Band tools so the agent knows how to use them, mirroring render_system_prompt in the other adapters. Guidance is appended to Agno's additional_context (so the developer's own instructions are preserved) and includes the base environment instructions plus the capability-gated memory and contact sections. Tests drive a real Agno agent through a capturing model and assert on the system prompt Agno actually assembles, rather than the attribute set. Convert make_agno_agent/started into composable factory fixtures (make_started_adapter) and use fully qualified helper imports. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 26 +++++ tests/adapters/agno/conftest.py | 97 +++++++++++++++++ tests/adapters/agno/helpers.py | 74 ++++++------- tests/adapters/agno/test_adapter.py | 136 +++++++++++++++++------- tests/adapters/agno/test_rehydration.py | 38 ++++--- 5 files changed, 277 insertions(+), 94 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index b59b12253..fb81f9cb1 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -25,6 +25,7 @@ agno_function_class, agno_message_class, ) +from band.runtime.prompts import BASE_INSTRUCTIONS, CONTACT_SECTION, MEMORY_SECTION if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent @@ -345,8 +346,33 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: len(wired), ", ".join(wired), ) + self._inject_band_instructions() self._band_tools_wired = True + def _inject_band_instructions(self) -> None: + """Append Band tool guidance to the copied agent's system message. + + Appended to Agno's ``additional_context`` so the developer's own + instructions are preserved. + """ + if self._agent is None: + return + + guidance = self._band_instructions() + existing = getattr(self._agent, "additional_context", None) + self._agent.additional_context = ( + f"{existing}\n\n{guidance}" if existing else guidance + ) + + def _band_instructions(self) -> str: + """Compose Band guidance gated on enabled capabilities.""" + parts: list[str] = [BASE_INSTRUCTIONS.strip()] + if Capability.MEMORY in self.features.capabilities: + parts.append(MEMORY_SECTION.strip()) + if Capability.CONTACTS in self.features.capabilities: + parts.append(CONTACT_SECTION.strip()) + return "\n\n".join(parts) + def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: """Convert Band tool schemas into Agno Functions.""" function_cls = agno_function_class() diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index a06f72420..0da144ae7 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -5,12 +5,109 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + import pytest +from agno.agent import Agent as AgnoAgent +from agno.run.agent import RunOutput +from band.adapters.agno import AgnoAdapter +from band.core.types import AdapterFeatures, PlatformMessage from band.testing import FakeAgentTools +from tests.adapters.agno.helpers import CapturingModel, SchemaTools + @pytest.fixture def tools() -> FakeAgentTools: """A fresh, call-tracking Band tool surface for one test.""" return FakeAgentTools() + + +@pytest.fixture +def make_agno_agent() -> Callable[..., tuple[MagicMock, MagicMock]]: + """Factory returning ``(source_agent, copied_agent)`` fakes. + + ``deep_copy()`` returns the copy, mirroring how the adapter runs against a + copy of the developer's agent. The copy's ``arun`` yields ``response``. + """ + + def _make( + *, + update_memory_on_run: bool = False, + enable_agentic_memory: bool = False, + response: RunOutput | None = None, + ) -> tuple[MagicMock, MagicMock]: + source = MagicMock(name="source_agent") + source.update_memory_on_run = update_memory_on_run + source.enable_agentic_memory = enable_agentic_memory + + copy = MagicMock(name="copied_agent") + copy.add_tool = MagicMock() + # Real Agno agents default additional_context to None; mirror that. + copy.additional_context = None + copy.arun = AsyncMock( + return_value=response if response is not None else RunOutput() + ) + source.deep_copy = MagicMock(return_value=copy) + return source, copy + + return _make + + +@pytest.fixture +def make_started_adapter( + make_agno_agent: Callable[..., tuple[MagicMock, MagicMock]], +) -> Callable[..., Awaitable[tuple[AgnoAdapter, MagicMock]]]: + """Factory building an adapter past ``on_started``; returns + ``(adapter, copied_agent)``.""" + + async def _make( + response: RunOutput | None = None, + *, + features: AdapterFeatures | None = None, + ) -> tuple[AgnoAdapter, MagicMock]: + source, copy = make_agno_agent(response=response) + adapter = AgnoAdapter(source, features=features) + await adapter.on_started("TestBot", "desc") + return adapter, copy + + return _make + + +@pytest.fixture +def run_real_agent() -> Callable[..., Awaitable[CapturingModel]]: + """Factory that drives one bootstrap turn through a real Agno agent and a + capturing model, returning the model so a test can inspect the system prompt + Agno actually assembled and sent.""" + + async def _run( + msg: PlatformMessage, + *, + instructions: str = "You are Dev.", + additional_context: str | None = None, + features: AdapterFeatures | None = None, + ) -> CapturingModel: + agno = AgnoAgent( + model=CapturingModel(), + instructions=instructions, + additional_context=additional_context, + ) + adapter = AgnoAdapter(agno, features=features) + await adapter.on_started("Bot", "desc") + await adapter.on_message( + msg, + SchemaTools([]), + [], + None, + None, + is_session_bootstrap=True, + room_id=msg.room_id, + ) + assert adapter.agent is not None + model = adapter.agent.model + assert isinstance(model, CapturingModel) + return model + + return _run diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py index 698448e50..b85e4aa63 100644 --- a/tests/adapters/agno/helpers.py +++ b/tests/adapters/agno/helpers.py @@ -11,15 +11,13 @@ from __future__ import annotations from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock +from agno.models.base import Model from agno.models.message import Message -from agno.models.response import ToolExecution -from agno.run.agent import RunOutput +from agno.models.response import ModelResponse, ToolExecution -from band.adapters.agno import AgnoAdapter from band.core.types import ( - AdapterFeatures, AgentInput, HistoryProvider, PlatformMessage, @@ -27,30 +25,6 @@ from band.testing import FakeAgentTools -def make_agno_agent( - *, - update_memory_on_run: bool = False, - enable_agentic_memory: bool = False, - response: RunOutput | None = None, -) -> tuple[MagicMock, MagicMock]: - """Return (source_agent, copied_agent) fakes. - - ``deep_copy()`` returns the copy, mirroring how the adapter runs against a - copy of the developer's agent. The copy's ``arun`` yields ``response``. - """ - source = MagicMock(name="source_agent") - source.update_memory_on_run = update_memory_on_run - source.enable_agentic_memory = enable_agentic_memory - - copy = MagicMock(name="copied_agent") - copy.add_tool = MagicMock() - copy.arun = AsyncMock( - return_value=response if response is not None else RunOutput() - ) - source.deep_copy = MagicMock(return_value=copy) - return source, copy - - def tool_execution( name: str, *, @@ -68,16 +42,38 @@ def tool_execution( ) -async def started( - response: RunOutput | None = None, - *, - features: AdapterFeatures | None = None, -) -> tuple[AgnoAdapter, MagicMock]: - """Build an adapter past ``on_started`` and return (adapter, copied_agent).""" - source, copy = make_agno_agent(response=response) - adapter = AgnoAdapter(source, features=features) - await adapter.on_started("TestBot", "desc") - return adapter, copy +class CapturingModel(Model): + """A real Agno model that records the messages Agno asks it to respond to. + + Lets tests assert on the actual system prompt Agno assembles (the agent's + own instructions plus ``additional_context``), rather than the attribute the + adapter sets. Overriding ``aresponse`` skips the provider call path, so the + abstract invoke hooks are inert stubs. + """ + + def __init__(self, content: str = "ok") -> None: + super().__init__(id="capturing", provider="fake") + self._content = content + self.captured_messages: list[Message] | None = None + + def invoke(self, *args: Any, **kwargs: Any) -> Any: ... + async def ainvoke(self, *args: Any, **kwargs: Any) -> Any: ... + def invoke_stream(self, *args: Any, **kwargs: Any) -> Any: ... + async def ainvoke_stream(self, *args: Any, **kwargs: Any) -> Any: ... + def _parse_provider_response(self, *args: Any, **kwargs: Any) -> Any: ... + def _parse_provider_response_delta(self, *args: Any, **kwargs: Any) -> Any: ... + + async def aresponse(self, messages: list[Message], **kwargs: Any) -> ModelResponse: + self.captured_messages = messages + return ModelResponse(content=self._content) + + @property + def captured_system_prompt(self) -> str: + """The concatenated system message(s) Agno sent to the model.""" + messages = self.captured_messages or [] + return "\n".join( + m.content for m in messages if m.role == "system" and m.content + ) class SchemaTools(FakeAgentTools): diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 62db44001..4ce407d6f 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -25,17 +25,15 @@ from band.core.types import AdapterFeatures, Capability, Emit from band.testing import FakeAgentTools -from .helpers import ( +from tests.adapters.agno.helpers import ( SchemaTools, - make_agno_agent, openai_tool_schema, - started, tool_execution, ) class TestOnStarted: - async def test_runs_against_a_deep_copy_not_the_source(self): + async def test_runs_against_a_deep_copy_not_the_source(self, make_agno_agent): source, copy = make_agno_agent() adapter = AgnoAdapter(source) @@ -45,14 +43,16 @@ async def test_runs_against_a_deep_copy_not_the_source(self): assert adapter.agent is copy assert adapter.agent is not source - async def test_syncs_converter_identity(self): - adapter, _ = await started() + async def test_syncs_converter_identity(self, make_started_adapter): + adapter, _ = await make_started_adapter() assert adapter.history_converter._agent_name == "TestBot" class TestMemoryCollisionWarning: - def test_warns_on_update_memory_on_run_with_memory_capability(self): + def test_warns_on_update_memory_on_run_with_memory_capability( + self, make_agno_agent + ): source, _ = make_agno_agent(update_memory_on_run=True) with pytest.warns(UserWarning, match="update_memory_on_run"): @@ -60,7 +60,7 @@ def test_warns_on_update_memory_on_run_with_memory_capability(self): source, features=AdapterFeatures(capabilities={Capability.MEMORY}) ) - def test_warns_on_agentic_memory_with_memory_capability(self): + def test_warns_on_agentic_memory_with_memory_capability(self, make_agno_agent): source, _ = make_agno_agent(enable_agentic_memory=True) with pytest.warns(UserWarning, match="enable_agentic_memory"): @@ -68,7 +68,7 @@ def test_warns_on_agentic_memory_with_memory_capability(self): source, features=AdapterFeatures(capabilities={Capability.MEMORY}) ) - def test_no_warning_without_memory_capability(self): + def test_no_warning_without_memory_capability(self, make_agno_agent): source, _ = make_agno_agent( update_memory_on_run=True, enable_agentic_memory=True ) @@ -79,14 +79,16 @@ def test_no_warning_without_memory_capability(self): class TestBandToolWiring: - async def test_wires_each_schema_once(self, sample_platform_message): + async def test_wires_each_schema_once( + self, make_started_adapter, sample_platform_message + ): tools = SchemaTools( [ openai_tool_schema("band_send_message"), openai_tool_schema("band_lookup_peers"), ] ) - adapter, copy = await started() + adapter, copy = await make_started_adapter() await adapter.on_message( sample_platform_message, @@ -112,9 +114,11 @@ async def test_wires_each_schema_once(self, sample_platform_message): wired_names = [call.args[0].name for call in copy.add_tool.call_args_list] assert wired_names == ["band_send_message", "band_lookup_peers"] - async def test_capability_flags_drive_schema_request(self, sample_platform_message): + async def test_capability_flags_drive_schema_request( + self, make_started_adapter, sample_platform_message + ): tools = SchemaTools([]) - adapter, _ = await started( + adapter, _ = await make_started_adapter( features=AdapterFeatures( capabilities={Capability.MEMORY, Capability.CONTACTS} ) @@ -135,10 +139,10 @@ async def test_capability_flags_drive_schema_request(self, sample_platform_messa ] async def test_no_capabilities_excludes_memory_and_contacts( - self, sample_platform_message + self, make_started_adapter, sample_platform_message ): tools = SchemaTools([]) - adapter, _ = await started() + adapter, _ = await make_started_adapter() await adapter.on_message( sample_platform_message, @@ -155,6 +159,54 @@ async def test_no_capabilities_excludes_memory_and_contacts( ] +class TestBandInstructionInjection: + """Drive a real Agno agent so we assert on the system prompt Agno actually + assembled and sent to the model, not the attribute the adapter set.""" + + @pytest.mark.parametrize( + ("capabilities", "present", "absent"), + [ + (set(), [], ["## Memory Tools", "## Contact Management Tools"]), + ( + {Capability.MEMORY}, + ["## Memory Tools"], + ["## Contact Management Tools"], + ), + ( + {Capability.CONTACTS}, + ["## Contact Management Tools"], + ["## Memory Tools"], + ), + ], + ) + async def test_capability_sections_gated_in_model_prompt( + self, run_real_agent, sample_platform_message, capabilities, present, absent + ): + model = await run_real_agent( + sample_platform_message, + features=AdapterFeatures(capabilities=capabilities), + ) + prompt = model.captured_system_prompt + + assert "## Environment" in prompt # base guidance always injected + assert all(section in prompt for section in present) + assert all(section not in prompt for section in absent) + + async def test_developer_instructions_survive_in_prompt( + self, run_real_agent, sample_platform_message + ): + model = await run_real_agent( + sample_platform_message, + instructions="You are Dev, a niche specialist.", + additional_context="Keep replies under 10 words.", + ) + prompt = model.captured_system_prompt + + assert "You are Dev, a niche specialist." in prompt + assert "Keep replies under 10 words." in prompt + assert "## Environment" in prompt + + class TestBandEntrypointBinding: async def test_routes_to_execute_tool_call_inside_context(self, tools): entry = _make_band_entrypoint("band_lookup_peers") @@ -190,9 +242,9 @@ async def test_errors_outside_any_bound_context(self, tools): class TestReply: async def test_sends_fallback_text_when_agent_did_not_post( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): - adapter, _ = await started(RunOutput(content="hello")) + adapter, _ = await make_started_adapter(RunOutput(content="hello")) await adapter.on_message( sample_platform_message, @@ -207,12 +259,12 @@ async def test_sends_fallback_text_when_agent_did_not_post( tools.assert_message_sent(content="hello", mentions=["user-456"]) async def test_skips_fallback_when_agent_called_band_send_message( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): response = RunOutput( content="hello", tools=[tool_execution("band_send_message")] ) - adapter, _ = await started(response) + adapter, _ = await make_started_adapter(response) await adapter.on_message( sample_platform_message, @@ -226,8 +278,10 @@ async def test_skips_fallback_when_agent_called_band_send_message( tools.assert_no_messages_sent() - async def test_no_send_for_empty_content(self, sample_platform_message, tools): - adapter, _ = await started(RunOutput(content=" ")) + async def test_no_send_for_empty_content( + self, make_started_adapter, sample_platform_message, tools + ): + adapter, _ = await make_started_adapter(RunOutput(content=" ")) await adapter.on_message( sample_platform_message, @@ -244,12 +298,12 @@ async def test_no_send_for_empty_content(self, sample_platform_message, tools): class TestEmitExecution: async def test_emits_tool_call_and_result_events( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): response = RunOutput( tools=[tool_execution("band_lookup_peers", args={"page": "1"}, result="ok")] ) - adapter, _ = await started( + adapter, _ = await make_started_adapter( response, features=AdapterFeatures(emit={Emit.EXECUTION}) ) @@ -276,10 +330,10 @@ async def test_emits_tool_call_and_result_events( assert result_payload["is_error"] is False async def test_self_reporting_tools_are_not_re_emitted( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): response = RunOutput(tools=[tool_execution("band_send_message")]) - adapter, _ = await started( + adapter, _ = await make_started_adapter( response, features=AdapterFeatures(emit={Emit.EXECUTION}) ) @@ -296,10 +350,10 @@ async def test_self_reporting_tools_are_not_re_emitted( assert tools.events_sent == [] async def test_no_events_without_execution_emit( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): response = RunOutput(tools=[tool_execution("band_lookup_peers")]) - adapter, _ = await started(response) # no emit configured + adapter, _ = await make_started_adapter(response) # no emit configured await adapter.on_message( sample_platform_message, @@ -315,9 +369,11 @@ async def test_no_events_without_execution_emit( class TestEmitThoughts: - async def test_emits_reasoning_as_thought(self, sample_platform_message, tools): + async def test_emits_reasoning_as_thought( + self, make_started_adapter, sample_platform_message, tools + ): response = RunOutput(reasoning_content="thinking hard") - adapter, _ = await started( + adapter, _ = await make_started_adapter( response, features=AdapterFeatures(emit={Emit.THOUGHTS}) ) @@ -335,10 +391,10 @@ async def test_emits_reasoning_as_thought(self, sample_platform_message, tools): assert tools.events_sent[0]["content"] == "thinking hard" async def test_no_thought_without_thoughts_emit( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): response = RunOutput(reasoning_content="thinking hard") - adapter, _ = await started(response) # no emit configured + adapter, _ = await make_started_adapter(response) # no emit configured await adapter.on_message( sample_platform_message, @@ -352,8 +408,10 @@ async def test_no_thought_without_thoughts_emit( assert tools.events_sent == [] - async def test_no_thought_for_blank_reasoning(self, sample_platform_message, tools): - adapter, _ = await started( + async def test_no_thought_for_blank_reasoning( + self, make_started_adapter, sample_platform_message, tools + ): + adapter, _ = await make_started_adapter( RunOutput(reasoning_content=" "), features=AdapterFeatures(emit={Emit.THOUGHTS}), ) @@ -372,7 +430,7 @@ async def test_no_thought_for_blank_reasoning(self, sample_platform_message, too class TestPersistAndAccumulate: - def test_persist_keeps_only_conversation_roles(self): + def test_persist_keeps_only_conversation_roles(self, make_agno_agent): source, _ = make_agno_agent() adapter = AgnoAdapter(source) response = RunOutput( @@ -390,7 +448,9 @@ def test_persist_keeps_only_conversation_roles(self): kept = [m.role for m in adapter._message_history["room-1"]] assert kept == ["user", "assistant", "tool"] - def test_bootstrap_seeds_then_followup_accumulates(self, sample_platform_message): + def test_bootstrap_seeds_then_followup_accumulates( + self, make_agno_agent, sample_platform_message + ): source, _ = make_agno_agent() adapter = AgnoAdapter(source) seed = [Message(role="user", content="earlier")] @@ -420,7 +480,7 @@ def test_bootstrap_seeds_then_followup_accumulates(self, sample_platform_message class TestOnCleanup: - async def test_drops_room_transcript(self): + async def test_drops_room_transcript(self, make_agno_agent): source, _ = make_agno_agent() adapter = AgnoAdapter(source) adapter._message_history["room-1"] = [Message(role="user", content="hi")] @@ -429,7 +489,7 @@ async def test_drops_room_transcript(self): assert "room-1" not in adapter._message_history - async def test_unknown_room_is_noop(self): + async def test_unknown_room_is_noop(self, make_agno_agent): source, _ = make_agno_agent() adapter = AgnoAdapter(source) @@ -437,7 +497,7 @@ async def test_unknown_room_is_noop(self): class TestUsedBeforeStarted: - async def test_run_agent_before_on_started_raises(self): + async def test_run_agent_before_on_started_raises(self, make_agno_agent): source, _ = make_agno_agent() adapter = AgnoAdapter(source) diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py index c5ab02181..73a37ec2c 100644 --- a/tests/adapters/agno/test_rehydration.py +++ b/tests/adapters/agno/test_rehydration.py @@ -16,7 +16,7 @@ from band.runtime.formatters import format_history_for_llm from tests.framework_configs.fixtures import TOOL_CALL_SEARCH, TOOL_RESULT_SEARCH -from .helpers import make_agent_input, platform_msg, run_input, started +from tests.adapters.agno.helpers import make_agent_input, platform_msg, run_input class TestRehydrationPipeline: @@ -24,7 +24,7 @@ class TestRehydrationPipeline: actual run input Agno received.""" async def test_all_message_kinds_become_the_right_messages( - self, sample_platform_message + self, make_started_adapter, sample_platform_message ): # Authentic rehydration: build platform dicts and run them through the # real runtime formatter (which also drops the current message). @@ -51,7 +51,7 @@ async def test_all_message_kinds_become_the_right_messages( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await started(RunOutput(content="ack")) + adapter, copy = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) @@ -71,7 +71,9 @@ async def test_all_message_kinds_become_the_right_messages( assert msgs[3].tool_call_id == "tc_1" assert msgs[-1].content == sample_platform_message.format_for_llm() - async def test_unsupported_kinds_are_dropped(self, sample_platform_message): + async def test_unsupported_kinds_are_dropped( + self, make_started_adapter, sample_platform_message + ): raw = format_history_for_llm( [ platform_msg("h1", "hello", sender_name="Alice"), @@ -86,7 +88,7 @@ async def test_unsupported_kinds_are_dropped(self, sample_platform_message): ], exclude_id=sample_platform_message.id, ) - adapter, copy = await started(RunOutput(content="ack")) + adapter, copy = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) @@ -100,13 +102,13 @@ async def test_unsupported_kinds_are_dropped(self, sample_platform_message): ] async def test_history_is_from_history_but_current_message_is_live( - self, sample_platform_message + self, make_started_adapter, sample_platform_message ): raw = format_history_for_llm( [platform_msg("h1", "hi", sender_name="Alice")], exclude_id=sample_platform_message.id, ) - adapter, copy = await started(RunOutput(content="ack")) + adapter, copy = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) @@ -117,9 +119,9 @@ async def test_history_is_from_history_but_current_message_is_live( assert not msgs[-1].from_history # the message to actually answer async def test_participants_and_contacts_injected_before_current_message( - self, sample_platform_message + self, make_started_adapter, sample_platform_message ): - adapter, copy = await started(RunOutput(content="ok")) + adapter, copy = await make_started_adapter(RunOutput(content="ok")) await adapter.on_event( make_agent_input( @@ -141,7 +143,7 @@ async def test_participants_and_contacts_injected_before_current_message( class TestUnansweredMessage: async def test_current_message_excluded_from_history_then_answered( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): current = sample_platform_message # The platform context includes the current message; the formatter must @@ -156,7 +158,9 @@ async def test_current_message_excluded_from_history_then_answered( assert len(raw) == 1 assert all(current.content not in h["content"] for h in raw) - adapter, copy = await started(RunOutput(content="here is your answer")) + adapter, copy = await make_started_adapter( + RunOutput(content="here is your answer") + ) await adapter.on_event( make_agent_input(current, raw, is_session_bootstrap=True, tools=tools) @@ -171,7 +175,7 @@ async def test_current_message_excluded_from_history_then_answered( assert msgs[-1].content == formatted async def test_answers_unanswered_message_on_restart_bootstrap( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): # Agent restarts: first event is bootstrap, with a completed exchange in # history and a brand-new unanswered question as the current message. @@ -184,7 +188,7 @@ async def test_answers_unanswered_message_on_restart_bootstrap( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await started(RunOutput(content="fresh answer")) + adapter, copy = await make_started_adapter(RunOutput(content="fresh answer")) await adapter.on_event( make_agent_input( @@ -199,7 +203,7 @@ async def test_answers_unanswered_message_on_restart_bootstrap( assert run_input(copy)[-1].content == sample_platform_message.format_for_llm() async def test_trailing_unanswered_user_turns_are_preserved( - self, sample_platform_message, tools + self, make_started_adapter, sample_platform_message, tools ): # Several user turns with no assistant reply between them: agno keeps them # all as user messages (it does not require complete exchanges). @@ -211,7 +215,7 @@ async def test_trailing_unanswered_user_turns_are_preserved( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await started(RunOutput(content="answering all")) + adapter, copy = await make_started_adapter(RunOutput(content="answering all")) await adapter.on_event( make_agent_input( @@ -231,7 +235,7 @@ async def test_trailing_unanswered_user_turns_are_preserved( class TestMultiTurnCarryover: async def test_persisted_transcript_feeds_the_next_turn( - self, sample_platform_message + self, make_started_adapter, sample_platform_message ): # Turn 1's run produces a transcript; _persist_turn keeps it and the next # turn must build on top of it (carryover through the real on_message path). @@ -242,7 +246,7 @@ async def test_persisted_transcript_feeds_the_next_turn( Message(role="assistant", content="a1"), ], ) - adapter, copy = await started(turn) + adapter, copy = await make_started_adapter(turn) await adapter.on_event( make_agent_input(sample_platform_message, [], is_session_bootstrap=True) From 731ee9e0671240ceb5ba556573dbb3ae366840ec Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 10:34:18 +0300 Subject: [PATCH 38/90] test(agno): add memory E2E test with rate-limit-aware agent startup Add the Agno secretary memory E2E test (organization + subject scope) and fix the WebSocket 429 it tripped: two tests against one agent_id reconnect fast enough after a supersede that the platform rate-limits the connect. Promote the rate-limit-aware agent lifecycle out of the agno scenarios conftest into tests/e2e/helpers.py, collapsing the tenacity machinery into a single _connect_agent retry loop plus a running_agent context manager. All call sites (the memory test and the agno scenario tests) import running_agent directly from helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/adapters/test_agno_memory.py | 233 +++++++++++++++++++ tests/e2e/helpers.py | 77 +++++- tests/e2e/scenarios/agno/conftest.py | 111 +-------- tests/e2e/scenarios/agno/test_multi_agent.py | 2 +- tests/e2e/scenarios/agno/test_thoughts.py | 2 +- 5 files changed, 316 insertions(+), 109 deletions(-) create mode 100644 tests/e2e/adapters/test_agno_memory.py diff --git a/tests/e2e/adapters/test_agno_memory.py b/tests/e2e/adapters/test_agno_memory.py new file mode 100644 index 000000000..d178f9533 --- /dev/null +++ b/tests/e2e/adapters/test_agno_memory.py @@ -0,0 +1,233 @@ +"""E2E test for Agno memory tool usage at organization and subject scope. + +A generic "secretary" agent is given Band memory tools (``Capability.MEMORY``) +but its developer instructions never mention scope/system/type/segment. Correct +behavior therefore depends on the injected ``MEMORY_SECTION`` guidance, so a +passing test validates both the memory tools and the prompt-injection feature +(the Agno adapter appends ``MEMORY_SECTION`` to the agent's system prompt when +the memory capability is enabled). + +Each remembered fact carries a per-run UUID marker so it is identifiable on the +live platform; the created memories are archived in teardown unless ``--no-clean`` +(or ``BAND_TEST_NO_CLEAN``) is set. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest tests/e2e/adapters/test_agno_memory.py -v -s --no-cov +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncGenerator +from typing import Any +from uuid import uuid4 + +import pytest +from band_rest import AsyncRestClient + +from band import Agent +from band.core.types import AdapterFeatures, Capability +from tests.conftest_integration import is_no_clean_mode +from tests.e2e.conftest import ( + E2ESettings, + RoomAllocator, + requires_e2e, + requires_openai, +) +from tests.e2e.helpers import ( + TrackingWebSocketClient, + listening_for_agent_responses, + running_agent, + send_trigger_message, +) + +# Deliberately generic — no mention of scope/system/type/segment, so the agent +# must rely on the injected MEMORY_SECTION guidance to store memories correctly. +SECRETARY_INSTRUCTIONS = ( + "You are a personal secretary who helps the user remember facts for the long " + "run. Whenever the user shares something worth remembering, remember it " + "so you can recall it in future conversations, then briefly " + "confirm. Keep responses short." +) + + +@pytest.fixture +async def agno_memory_room( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + return await e2e_room_allocator("agno-memory") + + +@pytest.fixture +async def running_agno_memory_agent( + e2e_config: E2ESettings, +) -> AsyncGenerator[Agent, None]: + """Run an Agno secretary agent with Band memory tools enabled. + + Uses ``running_agent`` so the connect is retried with a cooldown when the + platform rate-limits a rapid reconnect after a recent supersede (HTTP 429), + which happens when both tests in this module run against one agent_id. + """ + from agno.agent import Agent as AgnoAgent + from agno.models.openai import OpenAIChat + + from band.adapters.agno import AgnoAdapter + + agno_agent = AgnoAgent( + model=OpenAIChat(id=e2e_config.e2e_llm_model), + instructions=SECRETARY_INSTRUCTIONS, + ) + adapter = AgnoAdapter( + agno_agent, + features=AdapterFeatures(capabilities={Capability.MEMORY}), + ) + + async with running_agent( + adapter, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ) as agent: + yield agent + + +@pytest.fixture +async def archived_memory_ids( + e2e_session_client: AsyncRestClient, + request: pytest.FixtureRequest, +) -> AsyncGenerator[list[str], None]: + """Collect memory IDs created by a test and archive them on teardown. + + Tests append the IDs they verified. Archiving (hide but preserve) keeps the + live organization clean across runs. Honors ``--no-clean`` / + ``BAND_TEST_NO_CLEAN`` so data can be inspected after a run. + """ + ids: list[str] = [] + yield ids + + if is_no_clean_mode(request): + return + for memory_id in ids: + with contextlib.suppress(Exception): + await e2e_session_client.agent_api_memories.archive_agent_memory( + id=memory_id + ) + + +async def _wait_for_memories( + client: AsyncRestClient, + marker: str, + *, + scope: str, + timeout: float, +) -> list[Any]: + """Poll until active ``scope`` memories contain ``marker``; return the matches.""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + response = await client.agent_api_memories.list_agent_memories( + page_size=50, status="active", scope=scope + ) + matches = [ + memory + for memory in response.data or [] + if marker in (getattr(memory, "content", None) or "") + ] + if matches: + return matches + await asyncio.sleep(1) + + pytest.fail(f"Expected {scope} memory containing {marker}") + + +# loop_scope="session" runs the agent's background task on the test's event loop +# so it processes the trigger concurrently with the test body. +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.flaky(reruns=2) +@requires_e2e +@requires_openai +async def test_agno_secretary_stores_organization_memory( + e2e_config: E2ESettings, + agno_memory_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, + e2e_user_client: AsyncRestClient, + running_agno_memory_agent: Agent, + ws_client: TrackingWebSocketClient, + archived_memory_ids: list[str], +) -> None: + """A shared/company fact is stored as an organization-scoped memory.""" + chat_id, _user_id, _user_name = agno_memory_room + agent_id, agent_name = e2e_agent_info + marker = f"AGNO_MEM_ORG_{uuid4().hex}" + prompt = ( + f"Remember this for the whole organization (so it can be shared everywhere): {marker} is the code name for our " + "Q3 launch." + ) + + async with listening_for_agent_responses( + ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True + ) as wait_for_reply: + await send_trigger_message( + e2e_user_client, chat_id, prompt, agent_name, agent_id + ) + await wait_for_reply() + + matches = await _wait_for_memories( + e2e_session_client, + marker, + scope="organization", + timeout=e2e_config.e2e_timeout, + ) + archived_memory_ids.extend(m.id for m in matches) + + +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.flaky(reruns=2) +@requires_e2e +@requires_openai +async def test_agno_secretary_stores_subject_memory( + e2e_config: E2ESettings, + agno_memory_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, + e2e_user_client: AsyncRestClient, + running_agno_memory_agent: Agent, + ws_client: TrackingWebSocketClient, + archived_memory_ids: list[str], +) -> None: + """A personal fact is stored as a subject-scoped memory linked to the user. + + The agent is only told the fact is "about me specifically" — it must infer + subject scope and resolve the user's subject_id (via band_lookup_peers / the + participant list) from the injected memory-scope guidance. + """ + chat_id, user_id, _user_name = agno_memory_room + agent_id, agent_name = e2e_agent_info + marker = f"AGNO_MEM_SUBJ_{uuid4().hex}" + prompt = ( + "Remember this about me personally so you recall it whenever we talk: " + f"{marker} — I prefer espresso over drip coffee. Save it as being about " + "me specifically." + ) + + async with listening_for_agent_responses( + ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True + ) as wait_for_reply: + await send_trigger_message( + e2e_user_client, chat_id, prompt, agent_name, agent_id + ) + await wait_for_reply() + + matches = await _wait_for_memories( + e2e_session_client, + marker, + scope="subject", + timeout=e2e_config.e2e_timeout, + ) + assert any(getattr(m, "subject_id", None) == user_id for m in matches), ( + f"Expected a subject memory containing {marker} linked to subject " + f"{user_id}, but matched subjects were " + f"{[getattr(m, 'subject_id', None) for m in matches]}." + ) + archived_memory_ids.extend(m.id for m in matches) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 90511a57b..7cb5a6814 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -7,11 +7,12 @@ from __future__ import annotations import asyncio +import contextlib import json import logging from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager -from typing import Any +from typing import TYPE_CHECKING, Any from rich.console import Console from rich.panel import Panel @@ -21,7 +22,13 @@ ChatMessageRequestMentionsItem as Mention, ) +from band.agent import Agent from band.client.streaming import MessageCreatedPayload, WebSocketClient +from band.client.streaming.errors import WebSocketUpgradeError +from band.core.simple_adapter import SimpleAdapter + +if TYPE_CHECKING: + from tests.e2e.conftest import E2ESettings logger = logging.getLogger(__name__) @@ -457,3 +464,71 @@ async def run_tool_execution_test( assert_content_contains(received, "PINEAPPLE") logger.info("[%s] Tool execution test passed", adapter_name) return received + + +# ============================================================================= +# Agent lifecycle with rate-limit-aware reconnect +# ============================================================================= + +# The platform rate-limits how often one agent_id may reopen its WebSocket after +# a recent supersede (HTTP 429); a fresh agent is built per attempt so a partial +# start never leaves a half-connected agent behind. +_RETRYABLE_WS_STATUS = frozenset({429, 503}) +_MAX_CONNECT_ATTEMPTS = 6 + + +async def _connect_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> Agent: + """Create and start an agent, retrying rate-limited (HTTP 429/503) connects. + + Waits the server-supplied ``retry_after`` (else exponential backoff) for up + to ``_MAX_CONNECT_ATTEMPTS`` tries. + """ + for attempt in range(1, _MAX_CONNECT_ATTEMPTS + 1): + agent = Agent.create( + adapter=adapter, + agent_id=agent_id, + api_key=api_key, + ws_url=config.band_ws_url, + rest_url=config.band_base_url, + ) + try: + await agent.start() + return agent + except WebSocketUpgradeError as exc: + with contextlib.suppress(Exception): + await agent.stop() + last_attempt = attempt == _MAX_CONNECT_ATTEMPTS + if exc.status_code not in _RETRYABLE_WS_STATUS or last_attempt: + raise + cooldown = float(exc.retry_after or min(2**attempt, 30)) + log_step( + "retry", + f"WebSocket rate-limited (HTTP {exc.status_code}); cooling down " + f"{cooldown:.0f}s before attempt {attempt + 1}", + ) + await asyncio.sleep(cooldown) + raise AssertionError("unreachable: loop returns or raises") + + +@asynccontextmanager +async def running_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> AsyncGenerator[Agent, None]: + """Run a started agent for the duration of the ``async with`` block.""" + agent = await _connect_agent( + adapter, agent_id=agent_id, api_key=api_key, config=config + ) + try: + yield agent + finally: + await agent.stop() diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index 6e16c1619..8dcbde04c 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -7,35 +7,24 @@ ``build_thinking_adapter``) - the grocery-list fixture data used by the multi-agent scenarios - direct-REST assertion helpers (tool execution, reported total, participant - presence) and the ``running_agent`` lifecycle context manager + presence) - dedicated room fixtures Generic, framework-agnostic E2E utilities (WebSocket listeners, trigger -messages, pretty logging, the second-agent fixtures) remain in -``tests/e2e/helpers.py`` and ``tests/e2e/conftest.py``. +messages, pretty logging, the second-agent fixtures, and the ``running_agent`` +lifecycle context manager) remain in ``tests/e2e/helpers.py`` and +``tests/e2e/conftest.py``. """ from __future__ import annotations import asyncio -import contextlib import logging -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager from typing import Any import pytest from band_rest import AsyncRestClient -from tenacity import ( - RetryCallState, - retry, - retry_if_exception, - stop_after_attempt, - wait_exponential, -) - -from band.agent import Agent -from band.client.streaming.errors import WebSocketUpgradeError + from band.core.simple_adapter import SimpleAdapter from tests.conftest_integration import fetch_all_context @@ -45,39 +34,6 @@ logger = logging.getLogger(__name__) -# The platform rate-limits how often a single agent may (re)open its WebSocket -# "after a recent supersede" (HTTP 429). The restart scenarios deliberately -# stop/start the same agent repeatedly, so back-to-back runs can trip this. -# Retry the connect with tenacity, honoring the server-supplied retry-after. -_RETRYABLE_WS_STATUS = frozenset({429, 503}) -_WS_CONNECT_ATTEMPTS = 6 - - -def _is_rate_limited_ws_error(exc: BaseException) -> bool: - return ( - isinstance(exc, WebSocketUpgradeError) - and exc.status_code in _RETRYABLE_WS_STATUS - ) - - -def _ws_retry_wait(retry_state: RetryCallState) -> float: - """Wait the server-supplied ``retry_after`` if present, else back off.""" - exc = retry_state.outcome.exception() if retry_state.outcome else None - if isinstance(exc, WebSocketUpgradeError) and exc.retry_after: - return float(exc.retry_after) - return wait_exponential(multiplier=2, min=2, max=30)(retry_state) - - -def _log_ws_retry(retry_state: RetryCallState) -> None: - exc = retry_state.outcome.exception() if retry_state.outcome else None - status = getattr(exc, "status_code", "?") - log_step( - "retry", - f"WebSocket rate-limited (HTTP {status}); cooling down before " - f"attempt {retry_state.attempt_number + 1}", - ) - - CALCULATOR_TOOL = "add_numbers" # Grocery prices chosen to sum cleanly in float (no rounding surprises) to a @@ -219,63 +175,6 @@ def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: # ============================================================================= -@retry( - retry=retry_if_exception(_is_rate_limited_ws_error), - wait=_ws_retry_wait, - stop=stop_after_attempt(_WS_CONNECT_ATTEMPTS), - before_sleep=_log_ws_retry, - reraise=True, -) -async def _start_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> Agent: - """Create and start an agent, retrying when the connect is rate-limited. - - A fresh ``Agent`` is built per attempt and a partial start is torn down - before tenacity retries, so a 429 leaves no half-connected agent behind. - """ - agent = Agent.create( - adapter=adapter, - agent_id=agent_id, - api_key=api_key, - ws_url=config.band_ws_url, - rest_url=config.band_base_url, - ) - try: - await agent.start() - except Exception: - with contextlib.suppress(Exception): - await agent.stop() - raise - return agent - - -@asynccontextmanager -async def running_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> AsyncGenerator[Agent, None]: - """Run an agent for the duration of the ``async with`` block. - - Wraps :func:`_start_agent` (which carries the tenacity retry) so callers - get clean start/stop bracketing. - """ - agent = await _start_agent( - adapter, agent_id=agent_id, api_key=api_key, config=config - ) - try: - yield agent - finally: - await agent.stop() - - async def wait_participant_absent( client: AsyncRestClient, room_id: str, diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py index 3db547538..3a2a6670f 100644 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -36,6 +36,7 @@ listening_for_room_activity, log_banner, log_step, + running_agent, send_trigger_message, ) from tests.e2e.scenarios.agno.conftest import ( @@ -46,7 +47,6 @@ create_calculator_agno_adapter, grocery_list_text, participant_present, - running_agent, wait_participant_absent, ) diff --git a/tests/e2e/scenarios/agno/test_thoughts.py b/tests/e2e/scenarios/agno/test_thoughts.py index ec135981f..cd07a2438 100644 --- a/tests/e2e/scenarios/agno/test_thoughts.py +++ b/tests/e2e/scenarios/agno/test_thoughts.py @@ -30,12 +30,12 @@ listening_for_room_activity, log_banner, log_step, + running_agent, send_trigger_message, ) from tests.e2e.scenarios.agno.conftest import ( assert_thought_emitted, build_thinking_adapter, - running_agent, ) logger = logging.getLogger(__name__) From 704bab8885b07361b803e82ec55e7d6ecee2a202 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 10:39:07 +0300 Subject: [PATCH 39/90] chore: format agno example --- examples/agno/03_tom_and_jerry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/agno/03_tom_and_jerry.py b/examples/agno/03_tom_and_jerry.py index 4867d1083..b9923821d 100644 --- a/examples/agno/03_tom_and_jerry.py +++ b/examples/agno/03_tom_and_jerry.py @@ -66,7 +66,9 @@ def load_environment() -> tuple[str, str]: return ws_url, rest_url -def build_agent(config_key: str, instructions: str, ws_url: str, rest_url: str) -> Agent: +def build_agent( + config_key: str, instructions: str, ws_url: str, rest_url: str +) -> Agent: """Build a Band agent backed by an in-character Agno agent.""" agno_agent = AgnoAgent( model=Claude(id="claude-sonnet-4-6"), From 69acc1584eaf5d01bac7244985259e6dc89110c3 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 12:00:24 +0300 Subject: [PATCH 40/90] test(e2e): refactor memory tests onto shared fixtures and split helpers/conftest Replace the ad-hoc, duplicated memory-test tooling with reusable primitives and organize the growing helper/conftest files by concern. Primitives (any memory-capable adapter test can build on these): - MemoryProbe via the `memory` fixture: marker() (meaningful + timestamped + random so the LLM keeps it and reruns can't collide), wait() (polls a scope, tracks matches, raises on subject scope without subject_id), archive-on-teardown. - send_and_wait_for_reply: the standard trigger-and-wait helper. Both encode the lessons from debugging so the same mistakes can't recur. Structure: - helpers.py -> helpers/ package by concern: log, messaging, agent, memory (__init__ re-exports the public API; call sites unchanged). - conftest.py fixtures -> fixtures/ plugin modules (clients, rooms, memory) loaded via pytest_plugins; conftest keeps settings, markers, and the collection hook. Behavior: - e2e_fresh_room_allocator makes a clean room per test (no inherited history) and, on teardown, removes the agent from those rooms so they don't count against the 10-room cap (no chat-delete API exists); opt out with --no-clean. - agno + langgraph memory tests use the shared primitives and fresh rooms. - Memory scope guidance points to band_get_participants for an in-room subject's id (band_lookup_peers excludes in-room peers); fix stray backtick. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/runtime/prompts.py | 6 +- tests/e2e/adapters/test_agno_memory.py | 146 +++---- tests/e2e/adapters/test_langgraph_memory.py | 71 +--- tests/e2e/conftest.py | 375 +----------------- tests/e2e/fixtures/__init__.py | 7 + tests/e2e/fixtures/clients.py | 158 ++++++++ tests/e2e/fixtures/memory.py | 29 ++ tests/e2e/fixtures/rooms.py | 267 +++++++++++++ tests/e2e/helpers/__init__.py | 42 ++ tests/e2e/helpers/agent.py | 84 ++++ tests/e2e/helpers/log.py | 49 +++ tests/e2e/helpers/memory.py | 91 +++++ .../e2e/{helpers.py => helpers/messaging.py} | 160 ++------ 13 files changed, 845 insertions(+), 640 deletions(-) create mode 100644 tests/e2e/fixtures/__init__.py create mode 100644 tests/e2e/fixtures/clients.py create mode 100644 tests/e2e/fixtures/memory.py create mode 100644 tests/e2e/fixtures/rooms.py create mode 100644 tests/e2e/helpers/__init__.py create mode 100644 tests/e2e/helpers/agent.py create mode 100644 tests/e2e/helpers/log.py create mode 100644 tests/e2e/helpers/memory.py rename tests/e2e/{helpers.py => helpers/messaging.py} (75%) diff --git a/src/band/runtime/prompts.py b/src/band/runtime/prompts.py index c596ff5f4..53923ae05 100644 --- a/src/band/runtime/prompts.py +++ b/src/band/runtime/prompts.py @@ -93,8 +93,10 @@ def _memory_type_lines() -> str: - How to perform a task: `system="{MemorySystem.LONG_TERM.value}"`, `type="{WorkingLongTermMemoryType.PROCEDURAL.value}"`, `segment="{MemorySegment.TOOL.value}"`""" -_MEMORY_SCOPE_GUIDANCE = f"""When storing with `scope="{MemoryStoreScope.SUBJECT.value}"`, you must pass a real `subject_id` UUID -(e.g. from `band_lookup_peers` or the participant list). +_MEMORY_SCOPE_GUIDANCE = f"""When storing with `scope="{MemoryStoreScope.SUBJECT.value}"`, you must pass a real `subject_id` UUID. +For someone in the current room (e.g. the user you are talking to), call `band_get_participants` +and use their `id`. For someone not in the room, use `band_lookup_peers`. +For cross-room memories, use `scope="{MemoryStoreScope.ORGANIZATION.value}"`. """ diff --git a/tests/e2e/adapters/test_agno_memory.py b/tests/e2e/adapters/test_agno_memory.py index d178f9533..79ebb8a27 100644 --- a/tests/e2e/adapters/test_agno_memory.py +++ b/tests/e2e/adapters/test_agno_memory.py @@ -7,9 +7,10 @@ (the Agno adapter appends ``MEMORY_SECTION`` to the agent's system prompt when the memory capability is enabled). -Each remembered fact carries a per-run UUID marker so it is identifiable on the -live platform; the created memories are archived in teardown unless ``--no-clean`` -(or ``BAND_TEST_NO_CLEAN``) is set. +Memory plumbing (unique markers, polling, teardown cleanup) comes from the shared +``memory`` fixture (``MemoryProbe``); the trigger-and-wait flow from +``send_and_wait_for_reply``. New memory tests should reuse those rather than +re-implementing them. Run with: E2E_TESTS_ENABLED=true uv run pytest tests/e2e/adapters/test_agno_memory.py -v -s --no-cov @@ -17,18 +18,13 @@ from __future__ import annotations -import asyncio -import contextlib from collections.abc import AsyncGenerator -from typing import Any -from uuid import uuid4 import pytest from band_rest import AsyncRestClient from band import Agent -from band.core.types import AdapterFeatures, Capability -from tests.conftest_integration import is_no_clean_mode +from band.core.types import AdapterFeatures, Capability, Emit from tests.e2e.conftest import ( E2ESettings, RoomAllocator, @@ -36,10 +32,10 @@ requires_openai, ) from tests.e2e.helpers import ( + MemoryProbe, TrackingWebSocketClient, - listening_for_agent_responses, running_agent, - send_trigger_message, + send_and_wait_for_reply, ) # Deliberately generic — no mention of scope/system/type/segment, so the agent @@ -54,9 +50,11 @@ @pytest.fixture async def agno_memory_room( - e2e_room_allocator: RoomAllocator, + e2e_fresh_room_allocator: RoomAllocator, ) -> tuple[str, str, str]: - return await e2e_room_allocator("agno-memory") + # A fresh room per test: the memory agent must not inherit unrelated history + # from reused rooms, which derails small models and pollutes the scope check. + return await e2e_fresh_room_allocator("agno-memory") @pytest.fixture @@ -78,9 +76,15 @@ async def running_agno_memory_agent( model=OpenAIChat(id=e2e_config.e2e_llm_model), instructions=SECRETARY_INSTRUCTIONS, ) + # Emit.EXECUTION posts the agent's tool_call/tool_result events to the room, + # so a failing run can be debugged by inspecting what the agent actually did + # (e.g. via band's REST context) instead of guessing. adapter = AgnoAdapter( agno_agent, - features=AdapterFeatures(capabilities={Capability.MEMORY}), + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION}, + ), ) async with running_agent( @@ -92,54 +96,6 @@ async def running_agno_memory_agent( yield agent -@pytest.fixture -async def archived_memory_ids( - e2e_session_client: AsyncRestClient, - request: pytest.FixtureRequest, -) -> AsyncGenerator[list[str], None]: - """Collect memory IDs created by a test and archive them on teardown. - - Tests append the IDs they verified. Archiving (hide but preserve) keeps the - live organization clean across runs. Honors ``--no-clean`` / - ``BAND_TEST_NO_CLEAN`` so data can be inspected after a run. - """ - ids: list[str] = [] - yield ids - - if is_no_clean_mode(request): - return - for memory_id in ids: - with contextlib.suppress(Exception): - await e2e_session_client.agent_api_memories.archive_agent_memory( - id=memory_id - ) - - -async def _wait_for_memories( - client: AsyncRestClient, - marker: str, - *, - scope: str, - timeout: float, -) -> list[Any]: - """Poll until active ``scope`` memories contain ``marker``; return the matches.""" - deadline = asyncio.get_running_loop().time() + timeout - while asyncio.get_running_loop().time() < deadline: - response = await client.agent_api_memories.list_agent_memories( - page_size=50, status="active", scope=scope - ) - matches = [ - memory - for memory in response.data or [] - if marker in (getattr(memory, "content", None) or "") - ] - if matches: - return matches - await asyncio.sleep(1) - - pytest.fail(f"Expected {scope} memory containing {marker}") - - # loop_scope="session" runs the agent's background task on the test's event loop # so it processes the trigger concurrently with the test body. @pytest.mark.asyncio(loop_scope="session") @@ -150,36 +106,31 @@ async def test_agno_secretary_stores_organization_memory( e2e_config: E2ESettings, agno_memory_room: tuple[str, str, str], e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, e2e_user_client: AsyncRestClient, running_agno_memory_agent: Agent, ws_client: TrackingWebSocketClient, - archived_memory_ids: list[str], + memory: MemoryProbe, ) -> None: """A shared/company fact is stored as an organization-scoped memory.""" chat_id, _user_id, _user_name = agno_memory_room agent_id, agent_name = e2e_agent_info - marker = f"AGNO_MEM_ORG_{uuid4().hex}" + marker = memory.marker("Q3LAUNCH") prompt = ( - f"Remember this for the whole organization (so it can be shared everywhere): {marker} is the code name for our " - "Q3 launch." + "Remember this for the whole organization (so it can be shared " + f"everywhere): the code name for our Q3 launch is {marker}." ) - async with listening_for_agent_responses( - ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True - ) as wait_for_reply: - await send_trigger_message( - e2e_user_client, chat_id, prompt, agent_name, agent_id - ) - await wait_for_reply() - - matches = await _wait_for_memories( - e2e_session_client, - marker, - scope="organization", + await send_and_wait_for_reply( + ws_client, + e2e_user_client, + chat_id, + prompt, + agent_name, + agent_id, timeout=e2e_config.e2e_timeout, ) - archived_memory_ids.extend(m.id for m in matches) + + await memory.wait(marker, scope="organization") @pytest.mark.asyncio(loop_scope="session") @@ -190,44 +141,39 @@ async def test_agno_secretary_stores_subject_memory( e2e_config: E2ESettings, agno_memory_room: tuple[str, str, str], e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, e2e_user_client: AsyncRestClient, running_agno_memory_agent: Agent, ws_client: TrackingWebSocketClient, - archived_memory_ids: list[str], + memory: MemoryProbe, ) -> None: """A personal fact is stored as a subject-scoped memory linked to the user. The agent is only told the fact is "about me specifically" — it must infer - subject scope and resolve the user's subject_id (via band_lookup_peers / the - participant list) from the injected memory-scope guidance. + subject scope and resolve the user's subject_id (via band_get_participants / + band_lookup_peers) from the injected memory-scope guidance. """ chat_id, user_id, _user_name = agno_memory_room agent_id, agent_name = e2e_agent_info - marker = f"AGNO_MEM_SUBJ_{uuid4().hex}" + marker = memory.marker("BADGE") prompt = ( "Remember this about me personally so you recall it whenever we talk: " - f"{marker} — I prefer espresso over drip coffee. Save it as being about " - "me specifically." + f"my employee badge number is {marker}. Save it as being about me " + "specifically." ) - async with listening_for_agent_responses( - ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True - ) as wait_for_reply: - await send_trigger_message( - e2e_user_client, chat_id, prompt, agent_name, agent_id - ) - await wait_for_reply() - - matches = await _wait_for_memories( - e2e_session_client, - marker, - scope="subject", + await send_and_wait_for_reply( + ws_client, + e2e_user_client, + chat_id, + prompt, + agent_name, + agent_id, timeout=e2e_config.e2e_timeout, ) + + matches = await memory.wait(marker, scope="subject", subject_id=user_id) assert any(getattr(m, "subject_id", None) == user_id for m in matches), ( f"Expected a subject memory containing {marker} linked to subject " f"{user_id}, but matched subjects were " f"{[getattr(m, 'subject_id', None) for m in matches]}." ) - archived_memory_ids.extend(m.id for m in matches) diff --git a/tests/e2e/adapters/test_langgraph_memory.py b/tests/e2e/adapters/test_langgraph_memory.py index 80543082e..4e8ac2978 100644 --- a/tests/e2e/adapters/test_langgraph_memory.py +++ b/tests/e2e/adapters/test_langgraph_memory.py @@ -6,9 +6,7 @@ from __future__ import annotations -import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable -from uuid import uuid4 import pytest from band_rest import AsyncRestClient @@ -18,9 +16,9 @@ from band.core.types import AdapterFeatures, Capability from tests.e2e.conftest import E2ESettings, requires_e2e, requires_openai from tests.e2e.helpers import ( + MemoryProbe, TrackingWebSocketClient, - listening_for_agent_responses, - send_trigger_message, + send_and_wait_for_reply, ) RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] @@ -33,9 +31,11 @@ @pytest.fixture async def langgraph_memory_room( - e2e_room_allocator: RoomAllocator, + e2e_fresh_room_allocator: RoomAllocator, ) -> tuple[str, str, str]: - return await e2e_room_allocator("langgraph-memory") + # A fresh room per test: the memory agent must not inherit unrelated history + # from reused rooms, which derails the model and can stall its reply. + return await e2e_fresh_room_allocator("langgraph-memory") @pytest.fixture @@ -65,31 +65,6 @@ async def running_langgraph_memory_agent( yield agent -async def _wait_for_org_memory_containing( - client: AsyncRestClient, - marker: str, - *, - timeout: float, -) -> None: - deadline = asyncio.get_running_loop().time() + timeout - - while asyncio.get_running_loop().time() < deadline: - response = await client.agent_api_memories.list_agent_memories( - page_size=50, - status="active", - scope="organization", - ) - if any( - marker in (getattr(memory, "content", None) or "") - for memory in response.data or [] - ): - return - - await asyncio.sleep(1) - - pytest.fail(f"Expected organization memory containing {marker}") - - # loop_scope="session" pins the test to the same event loop as the agent's # background processing task, so the agent processes the trigger concurrently with # the test body. A bare @pytest.mark.asyncio would run the body on a separate loop @@ -102,35 +77,29 @@ async def test_langgraph_agent_stores_durable_user_memory( e2e_config: E2ESettings, langgraph_memory_room: tuple[str, str, str], e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, e2e_user_client: AsyncRestClient, running_langgraph_memory_agent: Agent, ws_client: TrackingWebSocketClient, + memory: MemoryProbe, ) -> None: """Ask LangGraph to remember a durable preference and verify it is stored.""" chat_id, _user_id, _user_name = langgraph_memory_room agent_id, agent_name = e2e_agent_info - marker = f"LANGGRAPH_MEMORY_E2E_{uuid4().hex}" + marker = memory.marker("LGMEM") prompt = ( - "Remember this durable preference exactly: " - f"{marker} means I prefer concise memory test responses. " - "Store it as a long-term semantic user memory, then acknowledge it briefly." + "Remember this for the whole organization so anyone can recall it: the " + f"project code phrase {marker} means we keep responses concise. " + "Acknowledge it briefly." ) - async with listening_for_agent_responses( - ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True - ) as wait_for_reply: - await send_trigger_message( - e2e_user_client, - chat_id, - prompt, - agent_name, - agent_id, - ) - await wait_for_reply() - - await _wait_for_org_memory_containing( - e2e_session_client, - marker, + await send_and_wait_for_reply( + ws_client, + e2e_user_client, + chat_id, + prompt, + agent_name, + agent_id, timeout=e2e_config.e2e_timeout, ) + + await memory.wait(marker, scope="organization") diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 1179c2ae3..233735f64 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,4 +1,4 @@ -"""E2E test configuration and fixtures. +"""E2E test configuration: settings, skip markers, and plugin registration. E2E tests run adapters against a real Band platform with real (cheap) LLMs. They verify platform functionality and integration correctness, not LLM output quality. @@ -7,42 +7,44 @@ E2E_TESTS_ENABLED=true uv run pytest tests/e2e/ -v -s --no-cov Configuration is loaded from .env.test with E2E-specific overrides from env vars. + +Fixtures live in concern-focused plugin modules (loaded via ``pytest_plugins`` +below): ``fixtures.clients`` (config + REST/WS clients), ``fixtures.rooms`` (room +allocation + agent identity), ``fixtures.memory`` (memory toolkit). This module +keeps only what tests import by name — ``E2ESettings``, the ``requires_*`` +markers, and the ``RoomAllocator`` type — plus the collection hook. """ from __future__ import annotations import logging import os -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from pathlib import Path -from typing import TYPE_CHECKING import pytest from dotenv import load_dotenv from pydantic import ValidationError -from band_rest import AsyncRestClient, ChatRoomRequest -from band_rest.types import ( - ParticipantRequest, -) from thenvoi_testing.settings import BaseTestSettings -from band.client.streaming import WebSocketClient - -from tests.conftest_integration import is_room_alive -from tests.e2e.helpers import TrackingWebSocketClient - # Load .env.test into os.environ so LLM libraries (langchain, anthropic, etc.) # can pick up OPENAI_API_KEY, ANTHROPIC_API_KEY, and other keys. _ENV_TEST_PATH = Path(__file__).parent.parent.parent / ".env.test" load_dotenv(_ENV_TEST_PATH, override=False) -if TYPE_CHECKING: - from tests.e2e.adapters.conftest import AdapterFactory +logger = logging.getLogger(__name__) -# NOTE: pytestmark in conftest.py is NOT applied to collected tests. -# The 120s timeout is applied via pytest_collection_modifyitems below. +# Fixture plugins, grouped by concern. pytest_plugins must be declared in a +# conftest; listing the modules here keeps each fixture file small and focused. +pytest_plugins = ( + "tests.e2e.fixtures.clients", + "tests.e2e.fixtures.rooms", + "tests.e2e.fixtures.memory", +) -logger = logging.getLogger(__name__) +# Async callable: name -> (room_id, user_id, user_name). Shared by room fixtures +# and by tests that accept an allocator; defined here so both can import it. +RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @@ -68,10 +70,6 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: item.add_marker(timeout_marker) -# Platform limits agents to 10 active chat rooms; cap room searches accordingly. -_MAX_ROOMS_TO_SEARCH = 10 - - # ============================================================================= # E2E Settings # ============================================================================= @@ -141,338 +139,3 @@ def _check_e2e_status() -> tuple[bool, str]: not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set", ) - - -# ============================================================================= -# Fixtures -# ============================================================================= - - -@pytest.fixture(scope="session") -def e2e_config() -> E2ESettings: - """Provide E2E settings to tests (session-scoped singleton).""" - return E2ESettings() - - -@pytest.fixture(scope="session") -def e2e_created_room_ids() -> list[str]: - """Session-scoped mutable list tracking room IDs created during the E2E run. - - A mutable container is needed because session-scoped fixtures (like the - room allocator) append to this list during the run, and the room summary - fixture reads it at teardown. Using a list (not a set) preserves - creation order for the summary log. - """ - return [] - - -@pytest.fixture(scope="session", autouse=True) -def e2e_room_summary(e2e_created_room_ids: list[str]) -> Generator[None, None, None]: - """Log a summary of rooms created during the E2E test session. - - Rooms persist on the platform (no delete API for agents), so this - summary helps operators track accumulation across runs. - """ - yield - if e2e_created_room_ids: - logger.info( - "E2E session created %d room(s) that will persist: %s", - len(e2e_created_room_ids), - ", ".join(e2e_created_room_ids), - ) - - -@pytest.fixture(scope="session") -def e2e_session_client( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client shared across all E2E fixtures. - - Avoids creating multiple short-lived AsyncRestClient instances in each - session-scoped fixture. AsyncRestClient has no close() method — the - underlying httpx client is managed internally. - """ - if not e2e_config.band_api_key: - pytest.skip("BAND_API_KEY not set") - - return AsyncRestClient( - api_key=e2e_config.band_api_key, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture(scope="session") -def e2e_user_client( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client authenticated as the User. - - Used by ``send_trigger_message`` so the trigger comes from the User - (not the agent). The agent runtime skips self-authored messages, so - using the agent client would silently fail to trigger processing. - """ - if not e2e_config.band_api_key_user: - pytest.skip("BAND_API_KEY_USER not set (needed for user REST client)") - - return AsyncRestClient( - api_key=e2e_config.band_api_key_user, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture -def api_client( - e2e_user_client: AsyncRestClient, -) -> AsyncRestClient: - """Function-scoped alias for the user REST client. - - Tests inject ``api_client`` to send trigger messages. This now - resolves to the **user**-scoped client so the agent runtime correctly - processes the incoming message. - """ - return e2e_user_client - - -# ============================================================================= -# Per-Adapter Room Allocation -# ============================================================================= - - -# Async callable: adapter_name -> (room_id, user_id, user_name) -RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] - - -@pytest.fixture(scope="session") -async def e2e_room_allocator( - e2e_session_client: AsyncRestClient, - e2e_created_room_ids: list[str], -) -> RoomAllocator: - """Lazy per-adapter room allocator (session-scoped). - - Returns an async function ``allocate(name) -> (room_id, user_id, user_name)`` - that assigns a dedicated room to each adapter. Reuses existing rooms from - prior runs where possible; creates new rooms only when needed. - - The platform limits agents to 10 active rooms, and rooms persist (no delete - API). Each adapter gets its own room to avoid cross-adapter contamination - in room history. Expected allocation: 5 standard adapters + 1 Parlant + - 1 isolation Room B = 7 rooms max (well within the 10-room limit). - """ - client = e2e_session_client - cache: dict[str, tuple[str, str, str]] = {} - - # Find User peer once - peers_response = await client.agent_api_peers.list_agent_peers() - user_peer = next((p for p in peers_response.data if p.type == "User"), None) - if user_peer is None: - pytest.skip("No User peer available for E2E tests") - - # Collect existing rooms that are alive and already have this User peer. - # Rooms can be auto-deleted by the platform's 10-room limit, so we - # validate each room before considering it reusable. - chats_response = await client.agent_api_chats.list_agent_chats() - available_rooms: list[str] = [] - for room in (chats_response.data or [])[:_MAX_ROOMS_TO_SEARCH]: - if not await is_room_alive(client, room.id): - logger.warning("E2E: Room %s is deleted, skipping", room.id) - continue - participants_response = ( - await client.agent_api_participants.list_agent_chat_participants(room.id) - ) - participant_ids = [p.id for p in (participants_response.data or [])] - if user_peer.id in participant_ids: - available_rooms.append(room.id) - - logger.info( - "E2E: Found %d existing room(s) with User peer %s", - len(available_rooms), - user_peer.name, - ) - - used_room_ids: set[str] = set() - - async def allocate(name: str) -> tuple[str, str, str]: - if name in cache: - return cache[name] - - # Try to reuse an unassigned existing room - for room_id in available_rooms: - if room_id not in used_room_ids: - used_room_ids.add(room_id) - result = (room_id, user_peer.id, user_peer.name) - cache[name] = result - logger.info("E2E: Reusing room %s for '%s'", room_id, name) - return result - - # No existing room available — create one - response = await client.agent_api_chats.create_agent_chat( - chat=ChatRoomRequest() - ) - if response.data is None: - pytest.fail("create_agent_chat returned no data") - room_id = response.data.id - await client.agent_api_participants.add_agent_chat_participant( - room_id, - participant=ParticipantRequest(participant_id=user_peer.id, role="member"), - ) - used_room_ids.add(room_id) - e2e_created_room_ids.append(room_id) - result = (room_id, user_peer.id, user_peer.name) - cache[name] = result - logger.info( - "E2E: Created room %s for '%s' (will persist, no delete API)", - room_id, - name, - ) - return result - - return allocate - - -@pytest.fixture -async def e2e_adapter_room( - adapter_entry: tuple[str, AdapterFactory], - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for the current parametrized adapter. - - Returns (room_id, user_id, user_name). Each adapter gets its own room - to avoid cross-adapter contamination in room history. - """ - name, _ = adapter_entry - return await e2e_room_allocator(name) - - -@pytest.fixture -async def e2e_parlant_room( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for Parlant adapter tests.""" - return await e2e_room_allocator("parlant") - - -@pytest.fixture -async def e2e_isolation_room_b( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Shared Room B for room isolation tests. - - All adapters' isolation tests share this as their second room. - Room A is the adapter's own room (``e2e_adapter_room``). - """ - return await e2e_room_allocator("_isolation_b") - - -@pytest.fixture(scope="session") -async def e2e_agent_id(e2e_session_client: AsyncRestClient) -> str: - """Get the agent ID for the test agent (cached for the entire session). - - Note: Session-scoped because the agent ID is stable for a given API key - and never changes mid-run. If the underlying agent is recreated between - tests, this cached value would be stale — but that scenario doesn't - apply to E2E runs against a persistent platform. - """ - agent_me = await e2e_session_client.agent_api_identity.get_agent_me() - return agent_me.data.id - - -@pytest.fixture(scope="session") -async def e2e_agent_info(e2e_session_client: AsyncRestClient) -> tuple[str, str]: - """Get (agent_id, agent_name) for the test agent. - - Used by tests that need to @mention the agent in trigger messages. - """ - agent_me = await e2e_session_client.agent_api_identity.get_agent_me() - return agent_me.data.id, agent_me.data.name - - -@pytest.fixture(scope="session") -def e2e_session_client_2( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client for the *second* test agent. - - Multi-agent E2E tests need a distinct agent identity (different API key) - so two agents can coexist in the same room. Skips cleanly when the second - agent is not provisioned in .env.test. - """ - if not e2e_config.band_api_key_2: - pytest.skip("BAND_API_KEY_2 not set (needed for multi-agent E2E tests)") - - return AsyncRestClient( - api_key=e2e_config.band_api_key_2, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture(scope="session") -async def e2e_agent_info_2( - e2e_session_client_2: AsyncRestClient, -) -> tuple[str, str]: - """Get (agent_id, agent_name) for the second test agent. - - Used by multi-agent tests to @mention the second agent and to verify it - was added to / removed from a room. - """ - agent_me = await e2e_session_client_2.agent_api_identity.get_agent_me() - return agent_me.data.id, agent_me.data.name - - -@pytest.fixture(scope="session") -async def ws_client( - e2e_config: E2ESettings, -) -> AsyncGenerator[TrackingWebSocketClient, None]: - """Session-scoped WebSocket client for observing agent responses. - - Connects as the **User** (via ``band_api_key_user``) rather than - the agent. The platform enforces one WS connection per agent, so a - second agent connection would kill the Agent's own connection. The - User is a room participant and receives the same ``message_created`` - events, making it a safe observer that coexists with the Agent. - - Session-scoped to avoid creating/tearing down a WS connection per test, - which adds latency and can cause flakiness. - - Wraps the raw WebSocketClient in a TrackingWebSocketClient that tracks - joined channels and explicitly leaves them on teardown. - """ - if not e2e_config.band_api_key_user: - pytest.skip("BAND_API_KEY_USER not set (needed for WS observer)") - - ws = WebSocketClient( - ws_url=e2e_config.band_ws_url, - api_key=e2e_config.band_api_key_user, - agent_id=None, # User connection, not agent - ) - - async with ws: - tracking_ws = TrackingWebSocketClient(ws) - yield tracking_ws - await tracking_ws.cleanup_channels() - - -@pytest.fixture( - params=[ - "langgraph", - "anthropic", - "pydantic_ai", - "claude_sdk", - "crewai", - "agno", - ] -) -def adapter_entry( - request: pytest.FixtureRequest, -) -> tuple[str, AdapterFactory]: - """Parametrized fixture yielding (name, factory) for each adapter. - - Defined here (e2e/conftest.py) so both adapters/ and scenarios/ tests - share a single definition. The ADAPTER_FACTORIES import is deferred to - avoid a circular dependency (adapters/conftest.py imports E2ESettings - from this module). The ``AdapterFactory`` type is imported under - ``TYPE_CHECKING`` for the same reason. - """ - from tests.e2e.adapters.conftest import ADAPTER_FACTORIES - - name: str = request.param - return name, ADAPTER_FACTORIES[name] diff --git a/tests/e2e/fixtures/__init__.py b/tests/e2e/fixtures/__init__.py new file mode 100644 index 000000000..2f19c75a5 --- /dev/null +++ b/tests/e2e/fixtures/__init__.py @@ -0,0 +1,7 @@ +"""E2E fixture plugins, loaded via ``pytest_plugins`` in ``tests/e2e/conftest.py``. + +Split by concern: ``clients`` (config + REST/WS clients), ``rooms`` (room +allocation + agent identity), ``memory`` (memory-test toolkit). +""" + +from __future__ import annotations diff --git a/tests/e2e/fixtures/clients.py b/tests/e2e/fixtures/clients.py new file mode 100644 index 000000000..4250d0bef --- /dev/null +++ b/tests/e2e/fixtures/clients.py @@ -0,0 +1,158 @@ +"""Config + REST/WS client fixtures for E2E tests. + +Session-scoped singletons: the E2E settings, the agent/user REST clients, and +the User WebSocket observer. Also tracks rooms created during the run for the +end-of-session summary. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncGenerator, Generator + +import pytest +from band_rest import AsyncRestClient + +from band.client.streaming import WebSocketClient + +from tests.e2e.conftest import E2ESettings +from tests.e2e.helpers import TrackingWebSocketClient + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="session") +def e2e_config() -> E2ESettings: + """Provide E2E settings to tests (session-scoped singleton).""" + return E2ESettings() + + +@pytest.fixture(scope="session") +def e2e_created_room_ids() -> list[str]: + """Session-scoped mutable list tracking room IDs created during the E2E run. + + A mutable container is needed because session-scoped fixtures (like the + room allocator) append to this list during the run, and the room summary + fixture reads it at teardown. Using a list (not a set) preserves + creation order for the summary log. + """ + return [] + + +@pytest.fixture(scope="session", autouse=True) +def e2e_room_summary(e2e_created_room_ids: list[str]) -> Generator[None, None, None]: + """Log a summary of rooms created during the E2E test session. + + Rooms persist on the platform (no delete API for agents), so this + summary helps operators track accumulation across runs. + """ + yield + if e2e_created_room_ids: + logger.info( + "E2E session created %d room(s) that will persist: %s", + len(e2e_created_room_ids), + ", ".join(e2e_created_room_ids), + ) + + +@pytest.fixture(scope="session") +def e2e_session_client( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client shared across all E2E fixtures. + + Avoids creating multiple short-lived AsyncRestClient instances in each + session-scoped fixture. AsyncRestClient has no close() method — the + underlying httpx client is managed internally. + """ + if not e2e_config.band_api_key: + pytest.skip("BAND_API_KEY not set") + + return AsyncRestClient( + api_key=e2e_config.band_api_key, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture(scope="session") +def e2e_user_client( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client authenticated as the User. + + Used by ``send_trigger_message`` so the trigger comes from the User + (not the agent). The agent runtime skips self-authored messages, so + using the agent client would silently fail to trigger processing. + """ + if not e2e_config.band_api_key_user: + pytest.skip("BAND_API_KEY_USER not set (needed for user REST client)") + + return AsyncRestClient( + api_key=e2e_config.band_api_key_user, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture +def api_client( + e2e_user_client: AsyncRestClient, +) -> AsyncRestClient: + """Function-scoped alias for the user REST client. + + Tests inject ``api_client`` to send trigger messages. This now + resolves to the **user**-scoped client so the agent runtime correctly + processes the incoming message. + """ + return e2e_user_client + + +@pytest.fixture(scope="session") +def e2e_session_client_2( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client for the *second* test agent. + + Multi-agent E2E tests need a distinct agent identity (different API key) + so two agents can coexist in the same room. Skips cleanly when the second + agent is not provisioned in .env.test. + """ + if not e2e_config.band_api_key_2: + pytest.skip("BAND_API_KEY_2 not set (needed for multi-agent E2E tests)") + + return AsyncRestClient( + api_key=e2e_config.band_api_key_2, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture(scope="session") +async def ws_client( + e2e_config: E2ESettings, +) -> AsyncGenerator[TrackingWebSocketClient, None]: + """Session-scoped WebSocket client for observing agent responses. + + Connects as the **User** (via ``band_api_key_user``) rather than + the agent. The platform enforces one WS connection per agent, so a + second agent connection would kill the Agent's own connection. The + User is a room participant and receives the same ``message_created`` + events, making it a safe observer that coexists with the Agent. + + Session-scoped to avoid creating/tearing down a WS connection per test, + which adds latency and can cause flakiness. + + Wraps the raw WebSocketClient in a TrackingWebSocketClient that tracks + joined channels and explicitly leaves them on teardown. + """ + if not e2e_config.band_api_key_user: + pytest.skip("BAND_API_KEY_USER not set (needed for WS observer)") + + ws = WebSocketClient( + ws_url=e2e_config.band_ws_url, + api_key=e2e_config.band_api_key_user, + agent_id=None, # User connection, not agent + ) + + async with ws: + tracking_ws = TrackingWebSocketClient(ws) + yield tracking_ws + await tracking_ws.cleanup_channels() diff --git a/tests/e2e/fixtures/memory.py b/tests/e2e/fixtures/memory.py new file mode 100644 index 000000000..aa43a4a3c --- /dev/null +++ b/tests/e2e/fixtures/memory.py @@ -0,0 +1,29 @@ +"""Memory-test fixture: a per-test ``MemoryProbe`` that cleans up on teardown.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +import pytest +from band_rest import AsyncRestClient + +from tests.conftest_integration import is_no_clean_mode +from tests.e2e.conftest import E2ESettings +from tests.e2e.helpers import MemoryProbe + + +@pytest.fixture +async def memory( + e2e_session_client: AsyncRestClient, + e2e_config: E2ESettings, + request: pytest.FixtureRequest, +) -> AsyncGenerator[MemoryProbe, None]: + """Memory-test toolkit: ``memory.marker(...)`` + ``await memory.wait(...)``. + + Archives whatever it matched on teardown (skipped under ``--no-clean`` / + ``BAND_TEST_NO_CLEAN``). Any memory-capable adapter test can depend on this. + """ + probe = MemoryProbe(e2e_session_client, default_timeout=e2e_config.e2e_timeout) + yield probe + if not is_no_clean_mode(request): + await probe.archive_all() diff --git a/tests/e2e/fixtures/rooms.py b/tests/e2e/fixtures/rooms.py new file mode 100644 index 000000000..3e017de0a --- /dev/null +++ b/tests/e2e/fixtures/rooms.py @@ -0,0 +1,267 @@ +"""Room allocation + agent identity fixtures for E2E tests. + +Two allocators: ``e2e_room_allocator`` reuses rooms across runs (to respect the +platform's 10-room cap) and ``e2e_fresh_room_allocator`` makes a clean room per +test and leaves it on teardown. Plus per-adapter room fixtures and the agent +identity lookups used to @mention agents in trigger messages. +""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +import pytest +from band_rest import AsyncRestClient, ChatRoomRequest +from band_rest.types import ParticipantRequest + +from tests.conftest_integration import is_no_clean_mode, is_room_alive +from tests.e2e.conftest import RoomAllocator + +if TYPE_CHECKING: + from tests.e2e.adapters.conftest import AdapterFactory + +logger = logging.getLogger(__name__) + +# Platform limits agents to 10 active chat rooms; cap room searches accordingly. +_MAX_ROOMS_TO_SEARCH = 10 + + +@pytest.fixture(scope="session") +async def e2e_room_allocator( + e2e_session_client: AsyncRestClient, + e2e_created_room_ids: list[str], +) -> RoomAllocator: + """Lazy per-adapter room allocator (session-scoped). + + Returns an async function ``allocate(name) -> (room_id, user_id, user_name)`` + that assigns a dedicated room to each adapter. Reuses existing rooms from + prior runs where possible; creates new rooms only when needed. + + The platform limits agents to 10 active rooms, and rooms persist (no delete + API). Each adapter gets its own room to avoid cross-adapter contamination + in room history. Expected allocation: 5 standard adapters + 1 Parlant + + 1 isolation Room B = 7 rooms max (well within the 10-room limit). + """ + client = e2e_session_client + cache: dict[str, tuple[str, str, str]] = {} + + # Find User peer once + peers_response = await client.agent_api_peers.list_agent_peers() + user_peer = next((p for p in peers_response.data if p.type == "User"), None) + if user_peer is None: + pytest.skip("No User peer available for E2E tests") + + # Collect existing rooms that are alive and already have this User peer. + # Rooms can be auto-deleted by the platform's 10-room limit, so we + # validate each room before considering it reusable. + chats_response = await client.agent_api_chats.list_agent_chats() + available_rooms: list[str] = [] + for room in (chats_response.data or [])[:_MAX_ROOMS_TO_SEARCH]: + if not await is_room_alive(client, room.id): + logger.warning("E2E: Room %s is deleted, skipping", room.id) + continue + participants_response = ( + await client.agent_api_participants.list_agent_chat_participants(room.id) + ) + participant_ids = [p.id for p in (participants_response.data or [])] + if user_peer.id in participant_ids: + available_rooms.append(room.id) + + logger.info( + "E2E: Found %d existing room(s) with User peer %s", + len(available_rooms), + user_peer.name, + ) + + used_room_ids: set[str] = set() + + async def allocate(name: str) -> tuple[str, str, str]: + if name in cache: + return cache[name] + + # Try to reuse an unassigned existing room + for room_id in available_rooms: + if room_id not in used_room_ids: + used_room_ids.add(room_id) + result = (room_id, user_peer.id, user_peer.name) + cache[name] = result + logger.info("E2E: Reusing room %s for '%s'", room_id, name) + return result + + # No existing room available — create one + response = await client.agent_api_chats.create_agent_chat( + chat=ChatRoomRequest() + ) + if response.data is None: + pytest.fail("create_agent_chat returned no data") + room_id = response.data.id + await client.agent_api_participants.add_agent_chat_participant( + room_id, + participant=ParticipantRequest(participant_id=user_peer.id, role="member"), + ) + used_room_ids.add(room_id) + e2e_created_room_ids.append(room_id) + result = (room_id, user_peer.id, user_peer.name) + cache[name] = result + logger.info( + "E2E: Created room %s for '%s' (will persist, no delete API)", + room_id, + name, + ) + return result + + return allocate + + +@pytest.fixture +async def e2e_fresh_room_allocator( + e2e_session_client: AsyncRestClient, + e2e_created_room_ids: list[str], + request: pytest.FixtureRequest, +) -> AsyncGenerator[RoomAllocator, None]: + """Allocate a brand-new room on every call, then leave it on teardown. + + Unlike ``e2e_room_allocator`` (which reuses rooms), this always creates a + fresh room so the agent starts with a clean, uncontaminated history — use it + for tests sensitive to prior room content (e.g. memory tests). + + On teardown the agent is removed from each created room so they don't count + against its 10-room cap (there's no chat-delete API; removing the agent + participant frees the slot). Opt out with ``--no-clean`` / + ``BAND_TEST_NO_CLEAN`` to leave the rooms intact for debugging. + """ + client = e2e_session_client + + peers_response = await client.agent_api_peers.list_agent_peers() + user_peer = next((p for p in peers_response.data if p.type == "User"), None) + if user_peer is None: + pytest.skip("No User peer available for E2E tests") + agent_me = await client.agent_api_identity.get_agent_me() + agent_id = agent_me.data.id + + created: list[str] = [] + + async def allocate(name: str) -> tuple[str, str, str]: + response = await client.agent_api_chats.create_agent_chat( + chat=ChatRoomRequest(title=f"e2e-{name}") + ) + if response.data is None: + pytest.fail("create_agent_chat returned no data") + room_id = response.data.id + await client.agent_api_participants.add_agent_chat_participant( + room_id, + participant=ParticipantRequest(participant_id=user_peer.id, role="member"), + ) + e2e_created_room_ids.append(room_id) + created.append(room_id) + logger.info("E2E: Created fresh room %s for '%s'", room_id, name) + return room_id, user_peer.id, user_peer.name + + yield allocate + + if is_no_clean_mode(request): + return + for room_id in created: + with contextlib.suppress(Exception): + await client.agent_api_participants.remove_agent_chat_participant( + room_id, agent_id + ) + + +@pytest.fixture +async def e2e_adapter_room( + adapter_entry: tuple[str, AdapterFactory], + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for the current parametrized adapter. + + Returns (room_id, user_id, user_name). Each adapter gets its own room + to avoid cross-adapter contamination in room history. + """ + name, _ = adapter_entry + return await e2e_room_allocator(name) + + +@pytest.fixture +async def e2e_parlant_room( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for Parlant adapter tests.""" + return await e2e_room_allocator("parlant") + + +@pytest.fixture +async def e2e_isolation_room_b( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Shared Room B for room isolation tests. + + All adapters' isolation tests share this as their second room. + Room A is the adapter's own room (``e2e_adapter_room``). + """ + return await e2e_room_allocator("_isolation_b") + + +@pytest.fixture(scope="session") +async def e2e_agent_id(e2e_session_client: AsyncRestClient) -> str: + """Get the agent ID for the test agent (cached for the entire session). + + Note: Session-scoped because the agent ID is stable for a given API key + and never changes mid-run. If the underlying agent is recreated between + tests, this cached value would be stale — but that scenario doesn't + apply to E2E runs against a persistent platform. + """ + agent_me = await e2e_session_client.agent_api_identity.get_agent_me() + return agent_me.data.id + + +@pytest.fixture(scope="session") +async def e2e_agent_info(e2e_session_client: AsyncRestClient) -> tuple[str, str]: + """Get (agent_id, agent_name) for the test agent. + + Used by tests that need to @mention the agent in trigger messages. + """ + agent_me = await e2e_session_client.agent_api_identity.get_agent_me() + return agent_me.data.id, agent_me.data.name + + +@pytest.fixture(scope="session") +async def e2e_agent_info_2( + e2e_session_client_2: AsyncRestClient, +) -> tuple[str, str]: + """Get (agent_id, agent_name) for the second test agent. + + Used by multi-agent tests to @mention the second agent and to verify it + was added to / removed from a room. + """ + agent_me = await e2e_session_client_2.agent_api_identity.get_agent_me() + return agent_me.data.id, agent_me.data.name + + +@pytest.fixture( + params=[ + "langgraph", + "anthropic", + "pydantic_ai", + "claude_sdk", + "crewai", + "agno", + ] +) +def adapter_entry( + request: pytest.FixtureRequest, +) -> tuple[str, AdapterFactory]: + """Parametrized fixture yielding (name, factory) for each adapter. + + The ADAPTER_FACTORIES import is deferred to avoid a circular dependency + (adapters/conftest.py imports E2ESettings from the e2e conftest). The + ``AdapterFactory`` type is imported under ``TYPE_CHECKING`` for the same + reason. + """ + from tests.e2e.adapters.conftest import ADAPTER_FACTORIES + + name: str = request.param + return name, ADAPTER_FACTORIES[name] diff --git a/tests/e2e/helpers/__init__.py b/tests/e2e/helpers/__init__.py new file mode 100644 index 000000000..92b8c5161 --- /dev/null +++ b/tests/e2e/helpers/__init__.py @@ -0,0 +1,42 @@ +"""E2E test helpers. + +Split by concern: ``log`` (pretty transcript), ``messaging`` (drive/observe +chat rooms), ``agent`` (agent lifecycle), ``memory`` (memory-test toolkit). +Import the public helpers straight from ``tests.e2e.helpers``; the submodules +are an implementation detail. +""" + +from __future__ import annotations + +from tests.e2e.helpers.agent import running_agent +from tests.e2e.helpers.log import log_banner, log_step +from tests.e2e.helpers.memory import MemoryProbe +from tests.e2e.helpers.messaging import ( + TrackingWebSocketClient, + assert_content_contains, + assert_no_content_contains, + find_tool_call_in_context, + listening_for_agent_responses, + listening_for_room_activity, + run_smoke_test, + run_tool_execution_test, + send_and_wait_for_reply, + send_trigger_message, +) + +__all__ = [ + "MemoryProbe", + "TrackingWebSocketClient", + "assert_content_contains", + "assert_no_content_contains", + "find_tool_call_in_context", + "listening_for_agent_responses", + "listening_for_room_activity", + "log_banner", + "log_step", + "run_smoke_test", + "run_tool_execution_test", + "running_agent", + "send_and_wait_for_reply", + "send_trigger_message", +] diff --git a/tests/e2e/helpers/agent.py b/tests/e2e/helpers/agent.py new file mode 100644 index 000000000..4d5ba70e4 --- /dev/null +++ b/tests/e2e/helpers/agent.py @@ -0,0 +1,84 @@ +"""Agent lifecycle for E2E tests: start an agent with rate-limit-aware reconnect. + +Use the ``running_agent`` context manager to start/stop an agent around a test. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +from band.agent import Agent +from band.client.streaming.errors import WebSocketUpgradeError +from band.core.simple_adapter import SimpleAdapter + +from tests.e2e.helpers.log import log_step + +if TYPE_CHECKING: + from tests.e2e.conftest import E2ESettings + +# The platform rate-limits how often one agent_id may reopen its WebSocket after +# a recent supersede (HTTP 429); a fresh agent is built per attempt so a partial +# start never leaves a half-connected agent behind. +_RETRYABLE_WS_STATUS = frozenset({429, 503}) +_MAX_CONNECT_ATTEMPTS = 6 + + +async def _connect_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> Agent: + """Create and start an agent, retrying rate-limited (HTTP 429/503) connects. + + Waits the server-supplied ``retry_after`` (else exponential backoff) for up + to ``_MAX_CONNECT_ATTEMPTS`` tries. + """ + for attempt in range(1, _MAX_CONNECT_ATTEMPTS + 1): + agent = Agent.create( + adapter=adapter, + agent_id=agent_id, + api_key=api_key, + ws_url=config.band_ws_url, + rest_url=config.band_base_url, + ) + try: + await agent.start() + return agent + except WebSocketUpgradeError as exc: + with contextlib.suppress(Exception): + await agent.stop() + last_attempt = attempt == _MAX_CONNECT_ATTEMPTS + if exc.status_code not in _RETRYABLE_WS_STATUS or last_attempt: + raise + cooldown = float(exc.retry_after or min(2**attempt, 30)) + log_step( + "retry", + f"WebSocket rate-limited (HTTP {exc.status_code}); cooling down " + f"{cooldown:.0f}s before attempt {attempt + 1}", + ) + await asyncio.sleep(cooldown) + raise AssertionError("unreachable: loop returns or raises") + + +@asynccontextmanager +async def running_agent( + adapter: SimpleAdapter[Any], + *, + agent_id: str, + api_key: str, + config: E2ESettings, +) -> AsyncGenerator[Agent, None]: + """Run a started agent for the duration of the ``async with`` block.""" + agent = await _connect_agent( + adapter, agent_id=agent_id, api_key=api_key, config=config + ) + try: + yield agent + finally: + await agent.stop() diff --git a/tests/e2e/helpers/log.py b/tests/e2e/helpers/log.py new file mode 100644 index 000000000..601a8e17a --- /dev/null +++ b/tests/e2e/helpers/log.py @@ -0,0 +1,49 @@ +"""Pretty logging for E2E tests — a followable transcript under ``pytest -s``. + +Use ``log_banner`` for section headers and ``log_step`` for in-scenario markers. +""" + +from __future__ import annotations + +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +# Rich renders a readable transcript under ``pytest -s`` and degrades to plain +# text when stdout is captured/non-tty. All dynamic text is passed through +# ``rich.text.Text`` (no markup parsing) so values like "[Milk $3.50]" can't +# be misread as style tags. +_console = Console() + +# Style + icon per step kind. Numeric/other steps fall back to the default. +_STEP_KINDS: dict[str, tuple[str, str]] = { + "assert": ("bold green", "✔"), + "restart": ("bold yellow", "⟳"), + "retry": ("bold dark_orange", "↻"), +} +_STEP_DEFAULT: tuple[str, str] = ("bold cyan", "▶") + + +def log_banner(title: str) -> None: + """Render a boxed section banner; green when it announces a pass.""" + passed = "PASS" in title.upper() + _console.print() + _console.print( + Panel( + Text(title, style="bold green" if passed else "bold bright_white"), + border_style="green" if passed else "bright_cyan", + padding=(0, 2), + expand=True, + ) + ) + + +def log_step(n: int | str | float, text: str) -> None: + """Render a color/icon-coded step marker within a scenario.""" + style, icon = _STEP_KINDS.get(str(n), _STEP_DEFAULT) + label = str(n) if str(n) in _STEP_KINDS else f"step {n}" + line = Text(" ") + line.append(f"{icon} {label}", style=style) + line.append(" ") + line.append(text, style="white") + _console.print(line) diff --git a/tests/e2e/helpers/memory.py b/tests/e2e/helpers/memory.py new file mode 100644 index 000000000..499acc300 --- /dev/null +++ b/tests/e2e/helpers/memory.py @@ -0,0 +1,91 @@ +"""Memory-test toolkit: markers, polling, and teardown archival. + +Tests get ``MemoryProbe`` from the ``memory`` fixture (see conftest). New +memory tests should reuse it rather than hand-rolling memory REST calls. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +import pytest +from band_rest import AsyncRestClient + + +class MemoryProbe: + """Memory-test helper: make markers, poll for stored memories, auto-archive on teardown. + + Get it from the ``memory`` fixture. Typical use:: + + marker = memory.marker("BADGE") # always make markers this way + ...trigger the agent... + matches = await memory.wait(marker, scope="subject", subject_id=user_id) + + Subject scope REQUIRES ``subject_id`` (else the list API returns nothing); ``wait()`` + raises if it's missing so you can't silently time out on that mistake. + """ + + def __init__(self, client: AsyncRestClient, *, default_timeout: float) -> None: + self._client = client + self._default_timeout = default_timeout + self._ids: list[str] = [] + + def marker(self, prefix: str) -> str: + """Return a unique marker the LLM keeps verbatim (prefix + timestamp + random). + + Opaque tokens get dropped when the model rewrites a fact, so weave the result in as + the fact's substance (e.g. ``f"badge number is {marker}"``). + """ + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"{prefix}-{ts}-{uuid4().hex[:8]}" + + async def wait( + self, + marker: str, + *, + scope: str, + subject_id: str | None = None, + timeout: float | None = None, + ) -> list[Any]: + """Poll active ``scope`` memories until one's content contains ``marker``. + + Returns the matches and tracks their ids for teardown archival. Raises + ``ValueError`` for subject scope without ``subject_id`` (the list API returns + nothing without it); ``pytest.fail`` on timeout. ``timeout`` defaults to the + configured E2E timeout. + """ + if scope == "subject" and subject_id is None: + raise ValueError('scope="subject" requires subject_id') + + kwargs: dict[str, Any] = {"page_size": 50, "status": "active", "scope": scope} + if subject_id is not None: + kwargs["subject_id"] = subject_id + + deadline = asyncio.get_running_loop().time() + ( + timeout or self._default_timeout + ) + while asyncio.get_running_loop().time() < deadline: + response = await self._client.agent_api_memories.list_agent_memories( + **kwargs + ) + matches = [ + memory + for memory in response.data or [] + if marker in (getattr(memory, "content", None) or "") + ] + if matches: + self._ids.extend(m.id for m in matches) + return matches + await asyncio.sleep(1) + + pytest.fail(f"Expected {scope} memory containing {marker}") + + async def archive_all(self) -> None: + """Archive every memory matched via ``wait`` (best effort). Called on teardown.""" + for memory_id in self._ids: + with contextlib.suppress(Exception): + await self._client.agent_api_memories.archive_agent_memory(id=memory_id) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers/messaging.py similarity index 75% rename from tests/e2e/helpers.py rename to tests/e2e/helpers/messaging.py index 7cb5a6814..f192f3ce6 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers/messaging.py @@ -1,82 +1,31 @@ -"""E2E test helper functions. +"""Driving and observing chat rooms in E2E tests. -Provides utilities for sending messages, waiting for agent responses, -and asserting on message content in E2E tests. +The core building blocks: ``TrackingWebSocketClient`` (a self-cleaning WS +wrapper), ``send_trigger_message`` / ``send_and_wait_for_reply`` to drive an +agent, the ``listening_for_*`` context managers to observe responses, and a few +assertion + smoke/tool workflow helpers. Prefer ``send_and_wait_for_reply`` over +hand-rolling the listen/send/wait dance. """ from __future__ import annotations import asyncio -import contextlib import json import logging from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any +from typing import Any -from rich.console import Console -from rich.panel import Panel -from rich.text import Text from band_rest import AsyncRestClient, ChatMessageRequest from band_rest.types import ( ChatMessageRequestMentionsItem as Mention, ) -from band.agent import Agent from band.client.streaming import MessageCreatedPayload, WebSocketClient -from band.client.streaming.errors import WebSocketUpgradeError -from band.core.simple_adapter import SimpleAdapter - -if TYPE_CHECKING: - from tests.e2e.conftest import E2ESettings logger = logging.getLogger(__name__) -# ============================================================================= -# Pretty logging (followable transcript when running with -s) -# ============================================================================= - -# Rich renders a readable transcript under ``pytest -s`` and degrades to plain -# text when stdout is captured/non-tty. All dynamic text is passed through -# ``rich.text.Text`` (no markup parsing) so values like "[Milk $3.50]" can't -# be misread as style tags. -_console = Console() - -# Style + icon per step kind. Numeric/other steps fall back to the default. -_STEP_KINDS: dict[str, tuple[str, str]] = { - "assert": ("bold green", "✔"), - "restart": ("bold yellow", "⟳"), - "retry": ("bold dark_orange", "↻"), -} -_STEP_DEFAULT: tuple[str, str] = ("bold cyan", "▶") - - -def log_banner(title: str) -> None: - """Render a boxed section banner; green when it announces a pass.""" - passed = "PASS" in title.upper() - _console.print() - _console.print( - Panel( - Text(title, style="bold green" if passed else "bold bright_white"), - border_style="green" if passed else "bright_cyan", - padding=(0, 2), - expand=True, - ) - ) - - -def log_step(n: int | str | float, text: str) -> None: - """Render a color/icon-coded step marker within a scenario.""" - style, icon = _STEP_KINDS.get(str(n), _STEP_DEFAULT) - label = str(n) if str(n) in _STEP_KINDS else f"step {n}" - line = Text(" ") - line.append(f"{icon} {label}", style=style) - line.append(" ") - line.append(text, style="white") - _console.print(line) - - class TrackingWebSocketClient: """Wrapper around WebSocketClient that tracks joined rooms for cleanup. @@ -311,6 +260,28 @@ async def wait() -> list[MessageCreatedPayload]: await ws_client.leave_chat_room_channel(room_id) +async def send_and_wait_for_reply( + ws_client: TrackingWebSocketClient, + user_client: AsyncRestClient, + chat_id: str, + prompt: str, + agent_name: str, + agent_id: str, + *, + timeout: float, +) -> None: + """Send a trigger message as the user and block until the agent replies (or timeout). + + The standard way to drive an agent in an E2E test — use instead of hand-rolling the + listen/send/wait dance. Raises on timeout. + """ + async with listening_for_agent_responses( + ws_client, chat_id, timeout=timeout, raise_on_timeout=True + ) as wait_for_reply: + await send_trigger_message(user_client, chat_id, prompt, agent_name, agent_id) + await wait_for_reply() + + def find_tool_call_in_context(items: list[Any], tool_name: str) -> bool: """Return True if any context item is a ``tool_call`` event for *tool_name*. @@ -385,11 +356,6 @@ def assert_no_content_contains( ) -# ============================================================================= -# Shared Test Workflows -# ============================================================================= - - async def run_smoke_test( ws_client: TrackingWebSocketClient, api_client: AsyncRestClient, @@ -464,71 +430,3 @@ async def run_tool_execution_test( assert_content_contains(received, "PINEAPPLE") logger.info("[%s] Tool execution test passed", adapter_name) return received - - -# ============================================================================= -# Agent lifecycle with rate-limit-aware reconnect -# ============================================================================= - -# The platform rate-limits how often one agent_id may reopen its WebSocket after -# a recent supersede (HTTP 429); a fresh agent is built per attempt so a partial -# start never leaves a half-connected agent behind. -_RETRYABLE_WS_STATUS = frozenset({429, 503}) -_MAX_CONNECT_ATTEMPTS = 6 - - -async def _connect_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> Agent: - """Create and start an agent, retrying rate-limited (HTTP 429/503) connects. - - Waits the server-supplied ``retry_after`` (else exponential backoff) for up - to ``_MAX_CONNECT_ATTEMPTS`` tries. - """ - for attempt in range(1, _MAX_CONNECT_ATTEMPTS + 1): - agent = Agent.create( - adapter=adapter, - agent_id=agent_id, - api_key=api_key, - ws_url=config.band_ws_url, - rest_url=config.band_base_url, - ) - try: - await agent.start() - return agent - except WebSocketUpgradeError as exc: - with contextlib.suppress(Exception): - await agent.stop() - last_attempt = attempt == _MAX_CONNECT_ATTEMPTS - if exc.status_code not in _RETRYABLE_WS_STATUS or last_attempt: - raise - cooldown = float(exc.retry_after or min(2**attempt, 30)) - log_step( - "retry", - f"WebSocket rate-limited (HTTP {exc.status_code}); cooling down " - f"{cooldown:.0f}s before attempt {attempt + 1}", - ) - await asyncio.sleep(cooldown) - raise AssertionError("unreachable: loop returns or raises") - - -@asynccontextmanager -async def running_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> AsyncGenerator[Agent, None]: - """Run a started agent for the duration of the ``async with`` block.""" - agent = await _connect_agent( - adapter, agent_id=agent_id, api_key=api_key, config=config - ) - try: - yield agent - finally: - await agent.stop() From 29e51cb8639a94687f7d243937a87fb58557f392 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 12:30:47 +0300 Subject: [PATCH 41/90] feat(agno): skip Band history rehydration when Agno manages its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a developer's Agno agent persists and replays its own history (add_history_to_context=True with a db), Band's platform-history rehydration collides with Agno's session store and contaminates the model context. Detect this at construction, warn, and stop feeding Band's transcript into the run input — Agno's db/session_id becomes the single source of prior turns. Band still stores its per-turn transcript; it just no longer rehydrates it. Tests: unit guard tests (detection gating, rehydration disabled, store preserved) plus an end-to-end round-trip over a real in-memory db with a mocked model proving history survives a restart and is loaded by Agno, not Band. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 48 +++++- tests/adapters/agno/conftest.py | 14 +- tests/adapters/agno/test_history_guard.py | 140 ++++++++++++++++++ .../adapters/agno/test_history_persistence.py | 119 +++++++++++++++ 4 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 tests/adapters/agno/test_history_guard.py create mode 100644 tests/adapters/agno/test_history_persistence.py diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index fb81f9cb1..43bf62fcc 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -125,6 +125,7 @@ def __init__( self._message_history: dict[str, list[Message]] = {} self._band_tools_wired = False + self._agno_manages_history = self._detect_agno_history(agent) self._warn_on_memory_collision(agent) @property @@ -132,6 +133,32 @@ def agent(self) -> AgnoAgent | None: """The running Agno agent, initialized in on_started.""" return self._agent + def _detect_agno_history(self, agent: AgnoAgent) -> bool: + """Detect whether Agno persists and replays its own history. + + Agno loads prior runs into context only when ``add_history_to_context`` + is set *and* a database is attached (without a ``db`` the feature is + inert). When it does, Band must not also rehydrate platform history into + the run input, or the two history sources collide and contaminate the + model context. Band still keeps its own per-turn transcript store; it + simply stops feeding it back into the run. + """ + manages = bool( + getattr(agent, "add_history_to_context", False) + and getattr(agent, "db", None) is not None + ) + if manages: + warnings.warn( + "This Agno agent manages its own conversation history " + "(add_history_to_context=True with a database). Band's history " + "rehydration is disabled to avoid contaminating the context; " + "Agno will replay prior turns from its database via session " + "persistence.", + UserWarning, + stacklevel=3, + ) + return manages + def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: """Warn when Band and Agno memory are both enabled.""" if Capability.MEMORY not in self.features.capabilities: @@ -230,14 +257,23 @@ def _build_run_input( is_session_bootstrap: bool, room_id: str, ) -> list[Message]: - """Build Agno input for this turn.""" - if is_session_bootstrap: - self._message_history[room_id] = list(history) - else: - self._message_history.setdefault(room_id, []) + """Build Agno input for this turn. + When the Agno agent manages its own history, build a fresh current-turn + list and do not seed or replay Band's transcript — Agno supplies prior + turns from its database. Otherwise seed and accumulate a Band-managed + transcript per room. + """ message_cls = agno_message_class() - messages = self._message_history[room_id] + if self._agno_manages_history: + messages: list[Message] = [] + else: + if is_session_bootstrap: + self._message_history[room_id] = list(history) + else: + self._message_history.setdefault(room_id, []) + messages = self._message_history[room_id] + if participants_msg: messages.append( message_cls(role="user", content=f"[System]: {participants_msg}") diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index 0da144ae7..bcdfb2dde 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -37,11 +37,17 @@ def _make( *, update_memory_on_run: bool = False, enable_agentic_memory: bool = False, + add_history_to_context: bool = False, + db: object | None = None, response: RunOutput | None = None, ) -> tuple[MagicMock, MagicMock]: source = MagicMock(name="source_agent") source.update_memory_on_run = update_memory_on_run source.enable_agentic_memory = enable_agentic_memory + # Explicit falsy defaults: a bare MagicMock would expose these as truthy + # auto-attributes and spuriously trip the history-management guard. + source.add_history_to_context = add_history_to_context + source.db = db copy = MagicMock(name="copied_agent") copy.add_tool = MagicMock() @@ -67,8 +73,14 @@ async def _make( response: RunOutput | None = None, *, features: AdapterFeatures | None = None, + add_history_to_context: bool = False, + db: object | None = None, ) -> tuple[AgnoAdapter, MagicMock]: - source, copy = make_agno_agent(response=response) + source, copy = make_agno_agent( + response=response, + add_history_to_context=add_history_to_context, + db=db, + ) adapter = AgnoAdapter(source, features=features) await adapter.on_started("TestBot", "desc") return adapter, copy diff --git a/tests/adapters/agno/test_history_guard.py b/tests/adapters/agno/test_history_guard.py new file mode 100644 index 000000000..5abfd470a --- /dev/null +++ b/tests/adapters/agno/test_history_guard.py @@ -0,0 +1,140 @@ +"""Guard for Agno-managed history. + +When the developer's Agno agent persists and replays its own history +(``add_history_to_context=True`` *with* a ``db``), Band must stop rehydrating +its transcript into the run input — otherwise the two history sources collide +and contaminate the context. Band still keeps its per-turn transcript store; it +simply no longer feeds it back into the run. + +These drive the real ``on_event`` -> ``AgnoHistoryConverter`` path and inspect +the exact ``list[Message]`` Agno received via the faked ``agent.arun(input=...)``. +""" + +from __future__ import annotations + +import warnings + +import pytest +from agno.models.message import Message +from agno.run.agent import RunOutput + +from band.adapters.agno import AgnoAdapter +from band.runtime.formatters import format_history_for_llm + +from tests.adapters.agno.helpers import make_agent_input, platform_msg, run_input + + +class TestDetection: + def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): + source, _ = make_agno_agent(add_history_to_context=True, db=object()) + + with pytest.warns(UserWarning, match="manages its own conversation history"): + adapter = AgnoAdapter(source) + + assert adapter._agno_manages_history is True + + @pytest.mark.parametrize( + ("add_history_to_context", "db"), + [ + (True, None), # history flag but no db -> Agno loads nothing + (False, object()), # db but flag off + (False, None), # neither + ], + ) + def test_no_guard_unless_both_set( + self, make_agno_agent, add_history_to_context, db + ): + source, _ = make_agno_agent( + add_history_to_context=add_history_to_context, db=db + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") # any history warning would fail here + adapter = AgnoAdapter(source) + + assert adapter._agno_manages_history is False + + +class TestRehydrationDisabled: + async def test_bootstrap_run_input_omits_rehydrated_history( + self, make_started_adapter, sample_platform_message + ): + raw = format_history_for_llm( + [ + platform_msg("h1", "Prior question", sender_name="Alice"), + platform_msg( + "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" + ), + ], + exclude_id=sample_platform_message.id, + ) + adapter, copy = await make_started_adapter( + RunOutput(content="ack"), add_history_to_context=True, db=object() + ) + + await adapter.on_event( + make_agent_input( + sample_platform_message, + raw, + is_session_bootstrap=True, + participants_msg="Alice and Bob are here", + ) + ) + + msgs = run_input(copy) + # Only the participants line and the current message — no rehydrated turns. + assert [m.content for m in msgs] == [ + "[System]: Alice and Bob are here", + sample_platform_message.format_for_llm(), + ] + + async def test_second_turn_does_not_carry_over_band_transcript( + self, make_started_adapter, sample_platform_message + ): + turn = RunOutput( + content="a1", + messages=[ + Message(role="user", content="[Alice]: q1"), + Message(role="assistant", content="a1"), + ], + ) + adapter, copy = await make_started_adapter( + turn, add_history_to_context=True, db=object() + ) + + await adapter.on_event( + make_agent_input(sample_platform_message, [], is_session_bootstrap=True) + ) + await adapter.on_event( + make_agent_input(sample_platform_message, [], is_session_bootstrap=False) + ) + + # The follow-up turn sends only the current message: Agno supplies prior + # turns from its own database, so Band must not replay turn 1. + msgs = run_input(copy) + assert [m.content for m in msgs] == [sample_platform_message.format_for_llm()] + + +class TestStorePreserved: + async def test_transcript_is_still_stored_when_guard_on( + self, make_started_adapter, sample_platform_message + ): + turn = RunOutput( + content="a1", + messages=[ + Message(role="user", content="[Alice]: q1"), + Message(role="assistant", content="a1"), + ], + ) + adapter, _ = await make_started_adapter( + turn, add_history_to_context=True, db=object() + ) + + room_id = sample_platform_message.room_id + await adapter.on_event( + make_agent_input(sample_platform_message, [], is_session_bootstrap=True) + ) + + # "Store the history, just don't rehydrate it": _persist_turn still records + # the transcript even though it is no longer fed back into the run input. + assert adapter._message_history[room_id] == turn.messages diff --git a/tests/adapters/agno/test_history_persistence.py b/tests/adapters/agno/test_history_persistence.py new file mode 100644 index 000000000..9e45d86d2 --- /dev/null +++ b/tests/adapters/agno/test_history_persistence.py @@ -0,0 +1,119 @@ +"""End-to-end proof that, when Agno owns history, Agno (not Band) supplies it. + +Unlike the unit tests in ``test_history_guard.py`` (which fake the Agno agent), +this drives a **real** ``AgnoAgent`` backed by a real in-memory database with a +fixed ``session_id``, mocking only the LLM via ``CapturingModel``. We run a turn, +"reset" the agent (a brand-new adapter/agent instance sharing the same db and +session), run another turn, and inspect the exact messages the model received. + +Source attribution relies on two non-overlapping markers. The turn-1 message is +persisted only to Agno's db and is never handed back to Band, so if it reappears +on turn 2 it can only have come from Agno. On turn 2 Band is handed a *distinct* +sentinel history; with the guard on that sentinel must be dropped. So a pass +means Agno supplied prior context and Band's rehydration was suppressed — which +is exactly the behaviour the guard protects. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from agno.agent import Agent as AgnoAgent +from agno.db.in_memory import InMemoryDb + +from band.adapters.agno import AgnoAdapter +from band.core.types import PlatformMessage +from band.runtime.formatters import format_history_for_llm + +from tests.adapters.agno.helpers import ( + CapturingModel, + SchemaTools, + make_agent_input, + platform_msg, +) + +BAND_SENTINEL = "BAND-REHYDRATED-SENTINEL" + +ROOM_ID = "room-roundtrip" + + +def _platform_message(msg_id: str, content: str) -> PlatformMessage: + return PlatformMessage( + id=msg_id, + room_id=ROOM_ID, + content=content, + sender_id="user-1", + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(timezone.utc), + ) + + +def _captured(adapter: AgnoAdapter) -> CapturingModel: + agent = adapter.agent + assert agent is not None + model = agent.model + assert isinstance(model, CapturingModel) + return model + + +async def test_history_survives_restart_and_is_loaded_by_agno_not_band(): + db = InMemoryDb() + + def build_agent(reply: str) -> AgnoAgent: + # Same db + session_id across instances models a persistent backend that + # outlives a single agent process. + return AgnoAgent( + model=CapturingModel(reply), + db=db, + session_id=ROOM_ID, + add_history_to_context=True, + instructions="You are Bot.", + ) + + # Construction warns that Band rehydration is disabled, and flags the guard. + with pytest.warns(UserWarning, match="manages its own conversation history"): + adapter = AgnoAdapter(build_agent("first answer")) + assert adapter._agno_manages_history is True + await adapter.on_started("Bot", "desc") + + # Turn 1 — Band supplies NO history (raw=[]); only the live message is sent. + first = _platform_message("m1", "remember the code is 42") + await adapter.on_event( + make_agent_input(first, [], is_session_bootstrap=True, tools=SchemaTools([])) + ) + assert not any(m.from_history for m in _captured(adapter).captured_messages or []) + + # "Reset": a brand-new adapter/agent instance pointed at the same db+session. + with pytest.warns(UserWarning, match="manages its own conversation history"): + adapter2 = AgnoAdapter(build_agent("second answer")) + await adapter2.on_started("Bot", "desc") + + # Turn 2 — hand Band a DISTINCT platform history. With the guard on it must + # be ignored; only Agno's own db history should reach the model. + second = _platform_message("m2", "what was the code?") + band_raw = format_history_for_llm( + [platform_msg("hX", BAND_SENTINEL, sender_name="Ghost")], + exclude_id=second.id, + ) + await adapter2.on_event( + make_agent_input( + second, band_raw, is_session_bootstrap=True, tools=SchemaTools([]) + ) + ) + + captured = _captured(adapter2).captured_messages or [] + # Band's rehydration is suppressed: its sentinel never reaches the model. + assert not any(BAND_SENTINEL in (m.content or "") for m in captured) + + users = [m for m in captured if m.role == "user"] + # The prior turn reappears tagged from_history -> loaded by Agno's db, not by + # Band (whose sentinel above was dropped). + rehydrated = [m for m in users if m.from_history] + assert any(m.content == first.format_for_llm() for m in rehydrated) + # The live message is the last user turn and is NOT history. + assert users[-1].content == second.format_for_llm() + assert users[-1].from_history is False From e9103988b4b6b105050fc0f615b1c07583b2e707 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 12:57:06 +0300 Subject: [PATCH 42/90] test(agno): add e2e for db-backed memory surviving a restart Live-platform smoke covering the db-backed Agno history scenario: an agent with add_history_to_context + a database is asked to remember a code, stopped and restarted against the same db/session, then reproduces the code after the restart; the conversation is also verified present in Band's REST context. The adapter's history guard is asserted engaged in this configuration. Scope is deliberately honest: a black-box e2e cannot observe the model's assembled context, so it does not attempt to attribute the history source or prove non-duplication (prior content can also surface via Band's bootstrap "answer the trailing unanswered message" path, which the guard does not govern). The rigorous no-rehydration / no-duplication proof remains the unit test test_history_persistence, which controls exactly what Band feeds. Uses the fresh-room allocator so a reused room's stale messages can't contaminate recall, and instructs the agent to echo the value verbatim to keep the recall assertion stable. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/scenarios/agno/conftest.py | 55 +++++- .../scenarios/agno/test_database_restart.py | 165 ++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/scenarios/agno/test_database_restart.py diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index 8dcbde04c..a12bdc22b 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -20,7 +20,7 @@ import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from band_rest import AsyncRestClient @@ -32,6 +32,9 @@ from tests.e2e.conftest import E2ESettings, RoomAllocator from tests.e2e.helpers import find_tool_call_in_context, log_step +if TYPE_CHECKING: + from band.adapters.agno import AgnoAdapter + logger = logging.getLogger(__name__) CALCULATOR_TOOL = "add_numbers" @@ -142,6 +145,41 @@ def build_assistant_adapter( return AgnoAdapter(agno_agent) +def build_db_backed_agno_adapter( + settings: E2ESettings, + *, + db: Any, + session_id: str, +) -> "AgnoAdapter": + """Build an Agno adapter whose agent owns its history via a database. + + ``add_history_to_context=True`` with a ``db`` makes Agno persist and replay + prior turns itself (keyed by ``session_id``). The AgnoAdapter detects this + and disables Band's own history rehydration, so prior context comes from + Agno alone and is not duplicated. Passing the *same* ``db`` object and + ``session_id`` to a second adapter instance models a restart against a + persistent backend. + """ + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + db=db, + session_id=session_id, + add_history_to_context=True, + instructions=( + "You are a helpful assistant with a long-term memory. Whenever you " + "acknowledge OR recall a value you were asked to remember, you MUST " + "include the exact value verbatim in your reply. Keep responses short." + ), + ) + return AgnoAdapter(agno_agent) + + def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: """Build an Agno adapter with reasoning enabled and thought reporting on. @@ -289,3 +327,18 @@ async def agno_thoughts_room( ) -> tuple[str, str, str]: """Dedicated room for the Agno thoughts scenario.""" return await e2e_room_allocator("agno_thoughts") + + +@pytest.fixture +async def agno_database_room( + e2e_fresh_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Fresh, uncontaminated room for the db-backed Agno restart scenario. + + Uses the fresh-room allocator (not the reusing one) on purpose: this + scenario disables Band's history rehydration, so the agent's only memory is + Agno's ephemeral db. A reused room's stale "remember X" messages from prior + runs would otherwise be answered on bootstrap and contaminate the recall + assertion with an old secret. + """ + return await e2e_fresh_room_allocator("agno_database_restart") diff --git a/tests/e2e/scenarios/agno/test_database_restart.py b/tests/e2e/scenarios/agno/test_database_restart.py new file mode 100644 index 000000000..e831e8b4d --- /dev/null +++ b/tests/e2e/scenarios/agno/test_database_restart.py @@ -0,0 +1,165 @@ +"""Live smoke: a db-backed Agno agent survives a restart on the full Band stack. + +The live-platform counterpart to the in-process round-trip in +``tests/adapters/agno/test_history_persistence.py``. It exercises the full Band +stack with a real Agno agent whose history is owned by a database +(``add_history_to_context=True`` + ``db``): + +1. **Talk to it** — a user asks the agent to remember a secret code. +2. **Reboot it** — the agent is stopped and a fresh instance is started against + the *same* db object and ``session_id`` (a persistent backend outliving the + process). +3. **It remembers** — the rebooted agent reproduces the code after restart. +4. **History reached Band infra** — the conversation is retrievable from Band's + REST context. + +Scope (deliberately honest): this is a black-box integration test. It cannot +observe the model's assembled context, so it does NOT attempt to prove the +*source* of the recalled history (Agno's db vs. Band) or the absence of +duplication — in the live runtime, prior content can also surface via Band's +"answer the trailing unanswered message" bootstrap path, which the guard does +not govern. The rigorous proof that Band does not rehydrate and the context is +not duplicated lives in the unit test ``test_history_persistence`` (it controls +exactly what Band feeds and asserts it is dropped). Here we additionally assert +the guard is *engaged* in this configuration (``_agno_manages_history``) as a +cheap white-box check. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest \ + tests/e2e/scenarios/agno/test_database_restart.py -v -s --no-cov --log-cli-level=INFO +""" + +from __future__ import annotations + +import logging +import uuid + +import pytest +from agno.db.in_memory import InMemoryDb +from band_rest import AsyncRestClient + +from tests.conftest_integration import fetch_all_context +from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + assert_content_contains, + listening_for_room_activity, + log_banner, + log_step, + running_agent, + send_trigger_message, +) +from tests.e2e.scenarios.agno.conftest import build_db_backed_agno_adapter + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@requires_e2e +class TestAgnoDatabaseRestart: + """A db-backed Agno agent remembers across a restart on the live Band stack.""" + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + async def test_db_backed_agent_remembers_after_restart( + self, + e2e_config: E2ESettings, + agno_database_room: tuple[str, str, str], + e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + room_id, _user_id, _user_name = agno_database_room + agent_id, agent_name = e2e_agent_info + timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) + run_id = uuid.uuid4().hex[:6] + secret_code = f"SECRET-{run_id}" + + # One db object shared across the "reboot" models a persistent backend; + # the fixed session_id keys the agent's stored conversation. + db = InMemoryDb() + + log_banner(f"Scenario: Agno db-backed memory across restart (run {run_id})") + + # --- Phase 1: start the agent and have it store the secret --- + log_step(1, f"starting db-backed Agno agent (room {room_id})") + adapter = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) + # Guard engaged: the adapter has disabled Band's history rehydration. + assert adapter._agno_manages_history is True + + async with running_agent( + adapter, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + log_step(2, f"user asks the agent to remember {secret_code}") + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_for_reply: + await send_trigger_message( + api_client, + room_id, + f"Please remember this secret code for later: {secret_code}. " + "Just confirm you will remember it.", + agent_name, + agent_id, + ) + await wait_for_reply() + + # --- Phase 2: reboot (fresh instance, same db + session) and recall --- + log_step("restart", "agent stopped; rebooting with the same db + session_id") + adapter2 = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) + assert adapter2._agno_manages_history is True + + async with running_agent( + adapter2, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + log_step(3, "user asks the rebooted agent to recall the code") + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_for_reply: + await send_trigger_message( + api_client, + room_id, + "What was the secret code I asked you to remember earlier? " + "Reply with just the code.", + agent_name, + agent_id, + ) + phase2_responses = await wait_for_reply() + + # The rebooted instance reproduces the code (db-backed memory survived the + # restart). Source attribution is unit-tested, not claimed here. + assert_content_contains(phase2_responses, secret_code) + log_step("assert", "rebooted agent reproduced the code after restart") + + # The conversation persisted to Band infra and is retrievable via REST. + log_step(4, "verifying the conversation persisted to Band infra via REST") + items = await fetch_all_context(e2e_session_client, room_id) + texts = [ + getattr(item, "content", "") or "" + for item in items + if getattr(item, "message_type", None) == "text" + ] + assert any(secret_code in text for text in texts), ( + f"Expected the secret code {secret_code} in Band's stored room " + f"context, but it was absent from {len(texts)} text message(s)." + ) + log_step("assert", "conversation persisted to Band infra (REST context)") + + log_banner(f"Scenario PASSED (run {run_id})") From b5e69a1a2e502407b8feb401e333e6f4e39ca0f4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:07:14 +0300 Subject: [PATCH 43/90] =?UTF-8?q?fix(agno):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20session=20isolation,=20tool=20filters,=20error=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production-readiness fixes for the Agno adapter from code review: - Per-room session isolation: pass session_id=room_id to arun via a configurable session_id_factory (default lambda room_id: room_id), overriding any session_id on the source agent so each Band room is an isolated Agno session. Documents the migration consequence and escape hatch. - Honor AdapterFeatures include/exclude/category filters in _build_band_tools via the shared filter_tool_schemas helper; add get_band_tool_category to runtime/tools.py and use it from langgraph (drop the redundant alias). - Additive Band-tool registration keyed by name (no duplicates); contact tool exposure decided in the adapter (CONTACTS capability or hub room), mirroring LangGraph. Documented as intentionally non-strict per-room visibility. - Emit a generic room-visible error event on agent-run failure before re-raising, without leaking exception text. - Validate BAND_WS_URL/BAND_REST_URL in examples 01/02 (match example 03). - Docs: raw-reasoning note on Emit.THOUGHTS, fallback-vs-prompt comment, and the sender_name own-agent-detection limitation in the converter. Adds unit tests for session id derivation, two-room isolation, hub contact exposure, additive wiring, feature filters, and error-event reporting. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/01_basic_agent.py | 18 +- examples/agno/02_tool_reporting.py | 18 +- src/band/adapters/agno.py | 110 ++++++- src/band/converters/agno.py | 5 + .../integrations/langgraph/langchain_tools.py | 18 +- src/band/runtime/tools.py | 13 + tests/adapters/agno/test_adapter.py | 276 +++++++++++++++++- tests/framework_configs/adapters.py | 2 +- tests/integrations/test_langgraph_tools.py | 9 +- 9 files changed, 419 insertions(+), 50 deletions(-) diff --git a/examples/agno/01_basic_agent.py b/examples/agno/01_basic_agent.py index 359ea42e0..c56be80ae 100644 --- a/examples/agno/01_basic_agent.py +++ b/examples/agno/01_basic_agent.py @@ -43,16 +43,24 @@ logger = logging.getLogger(__name__) -def load_environment() -> None: - """Load environment variables and validate required credentials.""" +def load_environment() -> tuple[str, str]: + """Load env vars, validate credentials, and return (ws_url, rest_url).""" load_dotenv() if not os.environ.get("ANTHROPIC_API_KEY"): raise ValueError("ANTHROPIC_API_KEY environment variable is required") + ws_url = os.environ.get("BAND_WS_URL") + rest_url = os.environ.get("BAND_REST_URL") + if not ws_url: + raise ValueError("BAND_WS_URL environment variable is required") + if not rest_url: + raise ValueError("BAND_REST_URL environment variable is required") + return ws_url, rest_url + async def main() -> None: - load_environment() + ws_url, rest_url = load_environment() # Build the Agno agent — you choose the model, instructions, and tools. agno_agent = AgnoAgent( @@ -66,8 +74,8 @@ async def main() -> None: agent = Agent.from_config( "agno_agent", adapter=adapter, - ws_url=os.environ.get("BAND_WS_URL"), - rest_url=os.environ.get("BAND_REST_URL"), + ws_url=ws_url, + rest_url=rest_url, ) logger.info("Starting Agno agent...") diff --git a/examples/agno/02_tool_reporting.py b/examples/agno/02_tool_reporting.py index 721674aac..a91a2341f 100644 --- a/examples/agno/02_tool_reporting.py +++ b/examples/agno/02_tool_reporting.py @@ -50,16 +50,24 @@ def get_weather(city: str) -> str: return f"It is 22°C and sunny in {city}." -def load_environment() -> None: - """Load environment variables and validate required credentials.""" +def load_environment() -> tuple[str, str]: + """Load env vars, validate credentials, and return (ws_url, rest_url).""" load_dotenv() if not os.environ.get("ANTHROPIC_API_KEY"): raise ValueError("ANTHROPIC_API_KEY environment variable is required") + ws_url = os.environ.get("BAND_WS_URL") + rest_url = os.environ.get("BAND_REST_URL") + if not ws_url: + raise ValueError("BAND_WS_URL environment variable is required") + if not rest_url: + raise ValueError("BAND_REST_URL environment variable is required") + return ws_url, rest_url + async def main() -> None: - load_environment() + ws_url, rest_url = load_environment() # The Agno agent owns its tools; the adapter reports their executions. agno_agent = AgnoAgent( @@ -77,8 +85,8 @@ async def main() -> None: agent = Agent.from_config( "agno_agent", adapter=adapter, - ws_url=os.environ.get("BAND_WS_URL"), - rest_url=os.environ.get("BAND_REST_URL"), + ws_url=ws_url, + rest_url=rest_url, ) logger.info("Starting Agno agent with tool reporting...") diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 43bf62fcc..2f2e53c5a 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -13,6 +13,7 @@ from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter +from band.core.tool_filter import filter_tool_schemas from band.core.types import ( AdapterFeatures, Capability, @@ -26,6 +27,7 @@ agno_message_class, ) from band.runtime.prompts import BASE_INSTRUCTIONS, CONTACT_SECTION, MEMORY_SECTION +from band.runtime.tools import get_band_tool_category if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent @@ -96,7 +98,13 @@ def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: class AgnoAdapter(SimpleAdapter[AgnoMessages]): - """Bridge a developer-built Agno agent to Band.""" + """Bridge a developer-built Agno agent to Band. + + Note on ``Emit.THOUGHTS``: when enabled, the agent's **raw** + ``reasoning_content`` is posted to the room as a thought event. This can + surface chain-of-thought and intermediate context, so it is strictly + opt-in — enable it only when that visibility is intended. + """ SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset( {Emit.EXECUTION, Emit.THOUGHTS} @@ -111,7 +119,20 @@ def __init__( *, history_converter: AgnoHistoryConverter | None = None, features: AdapterFeatures | None = None, + session_id_factory: Callable[[str], str] = lambda room_id: room_id, ) -> None: + """Bridge ``agent`` to Band. + + Args: + session_id_factory: Maps a Band ``room_id`` to the Agno + ``session_id`` used for that room's runs. Defaults to using the + ``room_id`` itself, so each Band room is an isolated Agno + session. This **overrides** any ``session_id`` configured on + ``agent``. Consequence: Agno DB history previously stored under + the agent's original ``session_id`` is no longer reused (runs + are keyed by ``room_id``). To keep a single shared session + across rooms, pass e.g. ``session_id_factory=lambda _r: "fixed"``. + """ super().__init__( history_converter=history_converter or AgnoHistoryConverter(), features=features, @@ -120,10 +141,15 @@ def __init__( # Keep caller configuration immutable; runtime wiring happens on the copy. self._source_agent = agent self._agent: AgnoAgent | None = None + self._session_id_factory = session_id_factory # Running per-room transcripts; bootstrap history seeds each room. self._message_history: dict[str, list[Message]] = {} - self._band_tools_wired = False + # Band tools are wired additively onto the single shared agent: the tool + # set is the union of what any room has needed so far. Tracking wired + # names keeps wiring idempotent (no duplicates, never removed). + self._wired_tool_names: set[str] = set() + self._band_instructions_injected = False self._agno_manages_history = self._detect_agno_history(agent) self._warn_on_memory_collision(agent) @@ -296,19 +322,33 @@ async def _run_agent( msg_id: str, ) -> RunOutput | None: """Run the Agno agent with the room's tools bound for this call.""" + session_id = self._session_id_factory(room_id) logger.debug( - "Room %s msg %s: running Agno agent (%d input messages)", + "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", room_id, msg_id, len(messages), + session_id, ) try: with _bind_room_tools(tools): - response = await agent.arun(input=messages) - except Exception as e: + response = await agent.arun(input=messages, session_id=session_id) + except Exception: + # Keep the user-facing payload generic; the full traceback is in the + # agent log via logger.exception. Exception text can include DB + # strings, paths, and tokens that must not surface in chat. logger.exception( - "Room %s msg %s: error running Agno agent: %s", room_id, msg_id, e + "Room %s msg %s: error running Agno agent", room_id, msg_id ) + try: + await tools.send_event( + content="Internal error while processing message; see agent logs.", + message_type="error", + ) + except Exception: + logger.exception( + "Room %s msg %s: failed to report error event", room_id, msg_id + ) raise if response is None: @@ -338,7 +378,14 @@ async def _send_reply( *, room_id: str, ) -> None: - """Send final text unless the agent already posted through Band.""" + """Send final text unless the agent already posted through Band. + + The shared base prompt tells the agent "plain text output is not + delivered" to steer it toward ``band_send_message`` (proper mentions + + events). This adapter still delivers final text here as a fallback + convenience for agents that return text directly; the fallback is + intentionally not advertised in the prompt. + """ if any( _tool_name(execution) == "band_send_message" for execution in _tool_executions(response) @@ -364,15 +411,29 @@ async def _send_reply( await tools.send_message(text, mentions=mentions) def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: - """Wire Band tools into the copied Agno agent once.""" - if self._band_tools_wired or self._agent is None: + """Additively wire this room's Band tools onto the shared agent. + + The agent accumulates the union of tools any room has needed. Wiring is + idempotent by name: a tool already wired (e.g. from an earlier room) is + not re-added. This means once a contact-hub room is seen, contact tool + schemas remain visible in all rooms on the shared agent -- intentional, + not strict per-room visibility. Execution stays room-correct regardless + because each tool entrypoint routes through the current room's + AgentTools via the ``_current_tools`` ContextVar. + """ + if self._agent is None: return - band_tools = self._build_band_tools(tools) + new_tools = [ + fn + for fn in self._build_band_tools(tools) + if fn.name not in self._wired_tool_names + ] wired: list[str] = [] - for fn in band_tools: + for fn in new_tools: try: self._agent.add_tool(fn) + self._wired_tool_names.add(fn.name) wired.append(fn.name) except RuntimeError as e: logger.warning("Could not wire Band tool %s: %s", fn.name, e) @@ -382,8 +443,9 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: len(wired), ", ".join(wired), ) - self._inject_band_instructions() - self._band_tools_wired = True + if not self._band_instructions_injected: + self._inject_band_instructions() + self._band_instructions_injected = True def _inject_band_instructions(self) -> None: """Append Band tool guidance to the copied agent's system message. @@ -410,11 +472,29 @@ def _band_instructions(self) -> str: return "\n\n".join(parts) def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: - """Convert Band tool schemas into Agno Functions.""" + """Convert Band tool schemas into Agno Functions. + + Honors the AdapterFeatures include/exclude/category filters via + :func:`filter_tool_schemas`. Contact tools are force-exposed for the + contact-hub room (mirrors LangGraph) regardless of the CONTACTS + capability gate. + """ function_cls = agno_function_class() + effective_include_contacts = ( + Capability.CONTACTS in self.features.capabilities + or bool(getattr(tools, "is_hub_room", False)) + ) schemas = tools.get_openai_tool_schemas( include_memory=Capability.MEMORY in self.features.capabilities, - include_contacts=Capability.CONTACTS in self.features.capabilities, + include_contacts=effective_include_contacts, + ) + schemas = filter_tool_schemas( + schemas, + self.features, + get_name=lambda s: s.get("function", {}).get("name", ""), + get_category=lambda s: get_band_tool_category( + s.get("function", {}).get("name", "") + ), ) band_tools: list[Function] = [] diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 20e5444e6..3304414b6 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -132,6 +132,11 @@ def _text_message(self, hist: dict[str, Any]) -> Message: # any(msg.from_history) check doesn't re-add stored session history. message_cls = agno_message_class() content = hist.get("content", "") + # Own-agent detection keys on sender_name, not a stable sender_id: + # formatted history dicts carry only sender_name (see + # band.runtime.formatters.format_message_for_llm). If two participants + # share a display name, or this agent is renamed, prior assistant turns + # may be mis-mapped to the user role. if hist.get("role") == "assistant" and hist.get("sender_name") == ( self._agent_name ): diff --git a/src/band/integrations/langgraph/langchain_tools.py b/src/band/integrations/langgraph/langchain_tools.py index b85c39d5d..930fc1353 100644 --- a/src/band/integrations/langgraph/langchain_tools.py +++ b/src/band/integrations/langgraph/langchain_tools.py @@ -17,10 +17,8 @@ from band.core.tool_filter import filter_tool_schemas from band.core.types import AdapterFeatures, Capability from band.runtime.tools import ( - CHAT_TOOL_NAMES, - CONTACT_TOOL_NAMES, - MEMORY_TOOL_NAMES, format_tool_validation_error, + get_band_tool_category, get_tool_description, iter_tool_definitions, ) @@ -28,18 +26,6 @@ logger = logging.getLogger(__name__) -_TOOL_CATEGORIES: dict[str, str] = { - **{name: "chat" for name in CHAT_TOOL_NAMES}, - **{name: "contacts" for name in CONTACT_TOOL_NAMES}, - **{name: "memory" for name in MEMORY_TOOL_NAMES}, -} - - -def get_langgraph_tool_category(name: str) -> str | None: - """Return the AdapterFeatures category for a LangGraph platform tool.""" - return _TOOL_CATEGORIES.get(name) - - def agent_tools_to_langchain( tools: AgentToolsProtocol, *, @@ -97,7 +83,7 @@ def agent_tools_to_langchain( definitions, features, get_name=lambda definition: definition.name, - get_category=lambda definition: get_langgraph_tool_category(definition.name), + get_category=lambda definition: get_band_tool_category(definition.name), ) platform_tools: list[Any] = [] diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index 7bdd80d69..60f7e2ae0 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -984,6 +984,19 @@ class ListMyPeersInput(BaseModel): CHAT_TOOL_NAMES: frozenset[str] = BASE_TOOL_NAMES - CONTACT_TOOL_NAMES MCP_TOOL_PREFIX: str = "mcp__band__" +# AdapterFeatures category for each platform tool name. Shared across adapters +# so include_categories filtering is consistent (chat/contacts/memory). +_TOOL_CATEGORIES: dict[str, str] = { + **{name: "chat" for name in CHAT_TOOL_NAMES}, + **{name: "contacts" for name in CONTACT_TOOL_NAMES}, + **{name: "memory" for name in MEMORY_TOOL_NAMES}, +} + + +def get_band_tool_category(name: str) -> str | None: + """Return the AdapterFeatures category ("chat"/"contacts"/"memory") for a tool.""" + return _TOOL_CATEGORIES.get(name) + def mcp_tool_names(names: frozenset[str]) -> list[str]: """Convert base tool names to MCP-prefixed names for Claude SDK. diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 4ce407d6f..3357658e9 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -11,6 +11,7 @@ import json import warnings +from datetime import datetime, timezone from typing import Any import pytest @@ -22,7 +23,7 @@ _bind_room_tools, _make_band_entrypoint, ) -from band.core.types import AdapterFeatures, Capability, Emit +from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage from band.testing import FakeAgentTools from tests.adapters.agno.helpers import ( @@ -32,6 +33,27 @@ ) +def _msg( + room_id: str, + content: str, + *, + msg_id: str = "m1", + sender_id: str = "user-1", +) -> PlatformMessage: + """A minimal PlatformMessage for driving on_message in a given room.""" + return PlatformMessage( + id=msg_id, + room_id=room_id, + content=content, + sender_id=sender_id, + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(timezone.utc), + ) + + class TestOnStarted: async def test_runs_against_a_deep_copy_not_the_source(self, make_agno_agent): source, copy = make_agno_agent() @@ -99,7 +121,7 @@ async def test_wires_each_schema_once( is_session_bootstrap=True, room_id="room-1", ) - # Second turn must not re-wire (the _band_tools_wired guard). + # Second turn must not re-wire (idempotent by name via _wired_tool_names). await adapter.on_message( sample_platform_message, tools, @@ -505,3 +527,253 @@ async def test_run_agent_before_on_started_raises(self, make_agno_agent): await adapter._run_agent( [], FakeAgentTools(), room_id="room-1", msg_id="m1" ) + + +class TestSessionIsolation: + async def test_arun_uses_room_id_as_session_id(self, make_started_adapter): + adapter, copy = await make_started_adapter() + + await adapter.on_message( + _msg("room-A", "hi"), + FakeAgentTools(), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + + assert copy.arun.await_args.kwargs["session_id"] == "room-A" + + async def test_custom_session_id_factory_is_used(self, make_agno_agent): + source, copy = make_agno_agent() + adapter = AgnoAdapter(source, session_id_factory=lambda room: f"sess::{room}") + await adapter.on_started("TestBot", "desc") + + await adapter.on_message( + _msg("room-A", "hi"), + FakeAgentTools(), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + + assert copy.arun.await_args.kwargs["session_id"] == "sess::room-A" + + async def test_two_rooms_get_isolated_sessions_and_inputs( + self, make_started_adapter + ): + adapter, copy = await make_started_adapter() + + await adapter.on_message( + _msg("room-A", "alpha-secret"), + FakeAgentTools(), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + await adapter.on_message( + _msg("room-B", "beta-secret"), + FakeAgentTools(), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-B", + ) + + calls = copy.arun.await_args_list + assert calls[0].kwargs["session_id"] == "room-A" + assert calls[1].kwargs["session_id"] == "room-B" + + room_b_input = " ".join(m.content or "" for m in calls[1].kwargs["input"]) + assert "beta-secret" in room_b_input + assert "alpha-secret" not in room_b_input + + +class TestHubContactExposure: + """The adapter decides contact exposure (mirrors LangGraph): the CONTACTS + capability OR a hub room force-includes contact tool schemas.""" + + async def test_normal_room_does_not_request_contacts(self, make_started_adapter): + adapter, _ = await make_started_adapter() + tools = SchemaTools([], room_id="room-A") + + await adapter.on_message( + _msg("room-A", "hi"), + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + + assert tools.schema_calls == [ + {"include_memory": False, "include_contacts": False} + ] + + async def test_hub_room_forces_contacts(self, make_started_adapter): + adapter, _ = await make_started_adapter() + tools = SchemaTools([], hub_room_id="hub", room_id="hub") + + await adapter.on_message( + _msg("hub", "hi"), + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="hub", + ) + + assert tools.schema_calls == [ + {"include_memory": False, "include_contacts": True} + ] + + async def test_contact_tools_added_additively_after_hub(self, make_started_adapter): + adapter, copy = await make_started_adapter() + + normal = SchemaTools( + [openai_tool_schema("band_send_message")], room_id="room-A" + ) + await adapter.on_message( + _msg("room-A", "hi"), + normal, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + assert [c.args[0].name for c in copy.add_tool.call_args_list] == [ + "band_send_message" + ] + + hub = SchemaTools( + [ + openai_tool_schema("band_send_message"), + openai_tool_schema("band_add_contact"), + ], + hub_room_id="hub", + room_id="hub", + ) + await adapter.on_message( + _msg("hub", "hi"), + hub, + [], + None, + None, + is_session_bootstrap=True, + room_id="hub", + ) + + # band_send_message is not re-added; band_add_contact is additively wired. + wired = [c.args[0].name for c in copy.add_tool.call_args_list] + assert wired == ["band_send_message", "band_add_contact"] + # A run still executes per message against the single shared agent. + assert copy.arun.await_count == 2 + + +class TestFeatureFilters: + """AdapterFeatures include/exclude/category filters gate which Band tools + are wired (parity with LangGraph).""" + + ALL_SCHEMAS = [ + openai_tool_schema("band_send_message"), # chat + openai_tool_schema("band_lookup_peers"), # chat + openai_tool_schema("band_store_memory"), # memory + openai_tool_schema("band_add_contact"), # contacts + ] + + async def _wired_names(self, adapter, copy) -> list[str]: + await adapter.on_message( + _msg("room-A", "hi"), + SchemaTools(self.ALL_SCHEMAS), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + return [c.args[0].name for c in copy.add_tool.call_args_list] + + async def test_include_tools_keeps_only_named(self, make_started_adapter): + adapter, copy = await make_started_adapter( + features=AdapterFeatures(include_tools=["band_send_message"]) + ) + + assert await self._wired_names(adapter, copy) == ["band_send_message"] + + async def test_exclude_tools_drops_named(self, make_started_adapter): + adapter, copy = await make_started_adapter( + features=AdapterFeatures(exclude_tools=["band_send_message"]) + ) + + names = await self._wired_names(adapter, copy) + assert "band_send_message" not in names + assert "band_lookup_peers" in names + + async def test_include_categories_keeps_only_category(self, make_started_adapter): + adapter, copy = await make_started_adapter( + features=AdapterFeatures(include_categories=["chat"]) + ) + + assert sorted(await self._wired_names(adapter, copy)) == [ + "band_lookup_peers", + "band_send_message", + ] + + +class TestRunFailureReporting: + async def test_emits_generic_error_event_and_reraises( + self, make_started_adapter, tools + ): + adapter, copy = await make_started_adapter() + copy.arun.side_effect = RuntimeError("db dsn leaked: secret-token") + + with pytest.raises(RuntimeError): + await adapter.on_message( + _msg("room-A", "hi"), + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + + errors = [e for e in tools.events_sent if e["message_type"] == "error"] + assert len(errors) == 1 + assert ( + errors[0]["content"] + == "Internal error while processing message; see agent logs." + ) + # The exception text (which can carry secrets) must not leak to the room. + assert "secret-token" not in errors[0]["content"] + + async def test_error_event_failure_does_not_mask_original( + self, make_started_adapter + ): + adapter, copy = await make_started_adapter() + copy.arun.side_effect = RuntimeError("boom") + + class _FailingEventTools(FakeAgentTools): + async def send_event(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise RuntimeError("event transport down") + + # The failed error-report must not replace the original exception. + with pytest.raises(RuntimeError, match="boom"): + await adapter.on_message( + _msg("room-A", "hi"), + _FailingEventTools(), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 74754988b..aee8ef671 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -683,7 +683,7 @@ def _build_agno_config() -> AdapterConfig: # owns those); assert the adapter-level state instead. expected_initial_values={ "agent": None, # the run copy is built in on_started - "_band_tools_wired": False, + "_wired_tool_names": set(), # tools are wired additively per room }, # No model/prompt kwargs to customize; nothing to assert here. custom_kwargs={}, diff --git a/tests/integrations/test_langgraph_tools.py b/tests/integrations/test_langgraph_tools.py index db4d21fdc..2f2af9147 100644 --- a/tests/integrations/test_langgraph_tools.py +++ b/tests/integrations/test_langgraph_tools.py @@ -7,11 +7,8 @@ import pytest from band.core.types import AdapterFeatures, Capability -from band.integrations.langgraph.langchain_tools import ( - agent_tools_to_langchain, - get_langgraph_tool_category, -) -from band.runtime.tools import iter_tool_definitions +from band.integrations.langgraph.langchain_tools import agent_tools_to_langchain +from band.runtime.tools import get_band_tool_category, iter_tool_definitions def _mock_agent_tools() -> MagicMock: @@ -95,7 +92,7 @@ def test_every_agent_tool_has_shared_category(self) -> None: include_memory=True, include_contacts=True, ) - if get_langgraph_tool_category(definition.name) is None + if get_band_tool_category(definition.name) is None ] assert missing == [] From 94862b97b2980510155c0ca71c0c4eef00ff9431 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:21:26 +0300 Subject: [PATCH 44/90] feat(agno): support agent_factory for fresh runtime agents Accept agent_factory alongside agent= (exactly one required). The agent= path still runs against agent.deep_copy() for immutability; agent_factory lets callers mint a fresh agent at startup without deep_copy() overhead. Defer agent-dependent checks (history detection, memory-collision warning) to on_started so the factory is only ever invoked at startup, not __init__. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/README.md | 17 ++- src/band/adapters/agno.py | 57 +++++++-- tests/adapters/agno/conftest.py | 8 ++ tests/adapters/agno/test_adapter.py | 108 ++++++++++++++++-- tests/adapters/agno/test_history_guard.py | 11 +- .../adapters/agno/test_history_persistence.py | 10 +- 6 files changed, 182 insertions(+), 29 deletions(-) diff --git a/examples/agno/README.md b/examples/agno/README.md index 61b2f36c9..6f32688cc 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -6,7 +6,7 @@ framework. ## Overview Agno is model-agnostic: you build and configure your own Agno `Agent` (model, -instructions, and — in a later iteration — tools), then bridge it to Band with +instructions, tools, database, and other Agno settings), then bridge it to Band with `AgnoAdapter`. The adapter converts Band room history into Agno messages, runs your agent, and replies with its text output. @@ -46,6 +46,19 @@ agent = Agent.from_config("agno_agent", adapter=adapter) await agent.run() ``` +Passing `agent=` runs the adapter against `agent.deep_copy()` so your instance +stays immutable. If you'd rather skip the deep-copy and hand the adapter a fresh +agent at startup, pass an `agent_factory` instead (provide exactly one): + +```python +adapter = AgnoAdapter( + agent_factory=lambda: AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are helpful.", + ) +) +``` + --- ## Examples @@ -55,6 +68,8 @@ await agent.run() | `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | | `02_tool_reporting.py` | **Tool-execution reporting** - An Agno agent with its own tools; `AdapterFeatures(emit={Emit.EXECUTION})` posts tool_call/tool_result events to the room. | | `03_tom_and_jerry.py` | **Two agents in one process** - Tom and Jerry, each its own Agno-backed Band agent with a distinct personality, run concurrently with `asyncio.gather`. | +| `04_memory_secretary.py` | **Band memory tools** - Enables `Capability.MEMORY` so an Agno agent can store and recall durable Band memories. | +| `05_agno_db_history.py` | **Agno-owned history** - Uses `db`, `session_id`, and `add_history_to_context=True`; the adapter disables Band history rehydration to avoid duplicate context. | --- diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 2f2e53c5a..a14a8adde 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -115,31 +115,62 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): def __init__( self, - agent: AgnoAgent, + agent: AgnoAgent | None = None, *, + agent_factory: Callable[[], AgnoAgent] | None = None, history_converter: AgnoHistoryConverter | None = None, features: AdapterFeatures | None = None, session_id_factory: Callable[[str], str] = lambda room_id: room_id, ) -> None: - """Bridge ``agent`` to Band. + """Bridge a developer-built Agno agent to Band. + + Provide **exactly one** of ``agent`` or ``agent_factory``: + + - ``agent``: a fully configured Agno agent. The adapter runs against + ``agent.deep_copy()`` so the caller's instance stays immutable. + - ``agent_factory``: a zero-arg callable returning a fresh Agno agent. + The adapter calls it once at startup, avoiding ``deep_copy()`` + overhead for callers that can cheaply mint a new agent:: + + adapter = AgnoAdapter( + agent_factory=lambda: AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions="You are helpful.", + ) + ) Args: session_id_factory: Maps a Band ``room_id`` to the Agno ``session_id`` used for that room's runs. Defaults to using the ``room_id`` itself, so each Band room is an isolated Agno session. This **overrides** any ``session_id`` configured on - ``agent``. Consequence: Agno DB history previously stored under + the agent. Consequence: Agno DB history previously stored under the agent's original ``session_id`` is no longer reused (runs are keyed by ``room_id``). To keep a single shared session across rooms, pass e.g. ``session_id_factory=lambda _r: "fixed"``. """ + if agent is not None and agent_factory is not None: + raise ValueError( + "AgnoAdapter accepts `agent` or `agent_factory`, not both." + ) + if agent is not None: + # Run against a copy so the caller's configured agent stays immutable. + factory: Callable[[], AgnoAgent] = agent.deep_copy + elif agent_factory is not None: + factory = agent_factory + else: + raise ValueError( + "AgnoAdapter requires exactly one of `agent` or `agent_factory`." + ) + super().__init__( history_converter=history_converter or AgnoHistoryConverter(), features=features, ) - # Keep caller configuration immutable; runtime wiring happens on the copy. - self._source_agent = agent + # The runtime agent is built once at startup (deep-copy or factory call), + # deferring any factory invocation out of __init__. + self._agent_factory = factory self._agent: AgnoAgent | None = None self._session_id_factory = session_id_factory @@ -151,8 +182,8 @@ def __init__( self._wired_tool_names: set[str] = set() self._band_instructions_injected = False - self._agno_manages_history = self._detect_agno_history(agent) - self._warn_on_memory_collision(agent) + # Resolved against the runtime agent in on_started, once it exists. + self._agno_manages_history = False @property def agent(self) -> AgnoAgent | None: @@ -206,10 +237,18 @@ def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: ) async def on_started(self, agent_name: str, agent_description: str) -> None: - """Deep-copy the caller's agent and sync the converter identity.""" + """Build the runtime agent and sync the converter identity. + + The runtime agent is produced by the factory captured at construction — + either the caller's ``agent.deep_copy`` or a developer ``agent_factory``. + Agent-dependent checks run here (not in ``__init__``) so the factory is + only ever invoked at startup. + """ await super().on_started(agent_name, agent_description) - self._agent = self._source_agent.deep_copy() + self._agent = self._agent_factory() + self._agno_manages_history = self._detect_agno_history(self._agent) + self._warn_on_memory_collision(self._agent) # Keep the converter's own-agent filtering in sync with our identity, so # rehydrated history maps this agent's past messages to the assistant role. diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index bcdfb2dde..3b60412df 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -56,6 +56,14 @@ def _make( copy.arun = AsyncMock( return_value=response if response is not None else RunOutput() ) + # The adapter detects history/memory management against the *runtime* + # agent (this copy), so mirror the source's config here too. Without + # these explicit values the bare MagicMock would expose truthy + # auto-attributes and spuriously trip the guards. + copy.update_memory_on_run = update_memory_on_run + copy.enable_agentic_memory = enable_agentic_memory + copy.add_history_to_context = add_history_to_context + copy.db = db source.deep_copy = MagicMock(return_value=copy) return source, copy diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 3357658e9..7e12ecfa6 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -13,6 +13,7 @@ import warnings from datetime import datetime, timezone from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest from agno.models.message import Message @@ -54,6 +55,25 @@ def _msg( ) +def _factory_agent_stub() -> MagicMock: + """A fake runtime agent as returned by an ``agent_factory``. + + Unlike the deep-copy path, the factory's agent is used as-is, so it carries + the falsy history/memory defaults and its own ``deep_copy`` to assert the + adapter never copies it. + """ + agent = MagicMock(name="factory_agent") + agent.update_memory_on_run = False + agent.enable_agentic_memory = False + agent.add_history_to_context = False + agent.db = None + agent.additional_context = None + agent.add_tool = MagicMock() + agent.arun = AsyncMock(return_value=RunOutput()) + agent.deep_copy = MagicMock() + return agent + + class TestOnStarted: async def test_runs_against_a_deep_copy_not_the_source(self, make_agno_agent): source, copy = make_agno_agent() @@ -71,33 +91,101 @@ async def test_syncs_converter_identity(self, make_started_adapter): assert adapter.history_converter._agent_name == "TestBot" +class TestAgentFactory: + """``agent_factory`` mints the runtime agent at startup without deep_copy().""" + + def test_factory_not_called_in_init(self): + factory = MagicMock(name="agent_factory") + + AgnoAdapter(agent_factory=factory) + + factory.assert_not_called() + + async def test_factory_called_once_in_on_started(self): + runtime_agent = _factory_agent_stub() + factory = MagicMock(name="agent_factory", return_value=runtime_agent) + adapter = AgnoAdapter(agent_factory=factory) + + await adapter.on_started("TestBot", "desc") + + factory.assert_called_once_with() + + async def test_factory_agent_used_directly_not_deep_copied(self): + runtime_agent = _factory_agent_stub() + adapter = AgnoAdapter(agent_factory=lambda: runtime_agent) + + await adapter.on_started("TestBot", "desc") + + assert adapter.agent is runtime_agent + # The factory's agent is used as-is; the adapter must not deep_copy it. + runtime_agent.deep_copy.assert_not_called() + + async def test_factory_built_adapter_runs_the_agent_and_replies(self, tools): + # End-to-end through the factory path: the factory's agent must be the + # one actually run on a message, and its output delivered to the room. + runtime_agent = _factory_agent_stub() + runtime_agent.arun = AsyncMock(return_value=RunOutput(content="hi from factory")) + adapter = AgnoAdapter(agent_factory=lambda: runtime_agent) + await adapter.on_started("TestBot", "desc") + + await adapter.on_message( + _msg("room-1", "hello"), + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + runtime_agent.arun.assert_awaited_once() + tools.assert_message_sent(content="hi from factory", mentions=["user-1"]) + + def test_neither_agent_nor_factory_raises(self): + with pytest.raises(ValueError, match="exactly one"): + AgnoAdapter() + + def test_both_agent_and_factory_raises(self, make_agno_agent): + source, _ = make_agno_agent() + + with pytest.raises(ValueError, match="not both"): + AgnoAdapter(source, agent_factory=lambda: source) + + class TestMemoryCollisionWarning: - def test_warns_on_update_memory_on_run_with_memory_capability( + """Collision is detected against the runtime agent at startup, not __init__.""" + + async def test_warns_on_update_memory_on_run_with_memory_capability( self, make_agno_agent ): source, _ = make_agno_agent(update_memory_on_run=True) + adapter = AgnoAdapter( + source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + ) with pytest.warns(UserWarning, match="update_memory_on_run"): - AgnoAdapter( - source, features=AdapterFeatures(capabilities={Capability.MEMORY}) - ) + await adapter.on_started("TestBot", "desc") - def test_warns_on_agentic_memory_with_memory_capability(self, make_agno_agent): + async def test_warns_on_agentic_memory_with_memory_capability( + self, make_agno_agent + ): source, _ = make_agno_agent(enable_agentic_memory=True) + adapter = AgnoAdapter( + source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + ) with pytest.warns(UserWarning, match="enable_agentic_memory"): - AgnoAdapter( - source, features=AdapterFeatures(capabilities={Capability.MEMORY}) - ) + await adapter.on_started("TestBot", "desc") - def test_no_warning_without_memory_capability(self, make_agno_agent): + async def test_no_warning_without_memory_capability(self, make_agno_agent): source, _ = make_agno_agent( update_memory_on_run=True, enable_agentic_memory=True ) + adapter = AgnoAdapter(source) # no MEMORY capability -> no collision with warnings.catch_warnings(): warnings.simplefilter("error") - AgnoAdapter(source) # no MEMORY capability -> no collision + await adapter.on_started("TestBot", "desc") class TestBandToolWiring: diff --git a/tests/adapters/agno/test_history_guard.py b/tests/adapters/agno/test_history_guard.py index 5abfd470a..d206fc17e 100644 --- a/tests/adapters/agno/test_history_guard.py +++ b/tests/adapters/agno/test_history_guard.py @@ -25,11 +25,13 @@ class TestDetection: - def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): + async def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): source, _ = make_agno_agent(add_history_to_context=True, db=object()) + adapter = AgnoAdapter(source) + # Detection runs against the runtime agent at startup, not in __init__. with pytest.warns(UserWarning, match="manages its own conversation history"): - adapter = AgnoAdapter(source) + await adapter.on_started("TestBot", "desc") assert adapter._agno_manages_history is True @@ -41,16 +43,17 @@ def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): (False, None), # neither ], ) - def test_no_guard_unless_both_set( + async def test_no_guard_unless_both_set( self, make_agno_agent, add_history_to_context, db ): source, _ = make_agno_agent( add_history_to_context=add_history_to_context, db=db ) + adapter = AgnoAdapter(source) with warnings.catch_warnings(): warnings.simplefilter("error") # any history warning would fail here - adapter = AgnoAdapter(source) + await adapter.on_started("TestBot", "desc") assert adapter._agno_manages_history is False diff --git a/tests/adapters/agno/test_history_persistence.py b/tests/adapters/agno/test_history_persistence.py index 9e45d86d2..f23223d57 100644 --- a/tests/adapters/agno/test_history_persistence.py +++ b/tests/adapters/agno/test_history_persistence.py @@ -74,11 +74,11 @@ def build_agent(reply: str) -> AgnoAgent: instructions="You are Bot.", ) - # Construction warns that Band rehydration is disabled, and flags the guard. + # Startup warns that Band rehydration is disabled, and flags the guard. + adapter = AgnoAdapter(build_agent("first answer")) with pytest.warns(UserWarning, match="manages its own conversation history"): - adapter = AgnoAdapter(build_agent("first answer")) + await adapter.on_started("Bot", "desc") assert adapter._agno_manages_history is True - await adapter.on_started("Bot", "desc") # Turn 1 — Band supplies NO history (raw=[]); only the live message is sent. first = _platform_message("m1", "remember the code is 42") @@ -88,9 +88,9 @@ def build_agent(reply: str) -> AgnoAgent: assert not any(m.from_history for m in _captured(adapter).captured_messages or []) # "Reset": a brand-new adapter/agent instance pointed at the same db+session. + adapter2 = AgnoAdapter(build_agent("second answer")) with pytest.warns(UserWarning, match="manages its own conversation history"): - adapter2 = AgnoAdapter(build_agent("second answer")) - await adapter2.on_started("Bot", "desc") + await adapter2.on_started("Bot", "desc") # Turn 2 — hand Band a DISTINCT platform history. With the guard on it must # be ignored; only Agno's own db history should reach the model. From e1f218d485aa394049da81b351003b5f576b4b81 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:21:46 +0300 Subject: [PATCH 45/90] Add Agno memory and DB history examples --- agent_config.yaml.example | 3 +- examples/agno/04_memory_secretary.py | 112 +++++++++++++++++++++++++++ examples/agno/05_agno_db_history.py | 111 ++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 examples/agno/04_memory_secretary.py create mode 100644 examples/agno/05_agno_db_history.py diff --git a/agent_config.yaml.example b/agent_config.yaml.example index 1d3f89086..335ad3d2b 100644 --- a/agent_config.yaml.example +++ b/agent_config.yaml.example @@ -171,7 +171,8 @@ gemini_agent: # Agno Examples # ============================================================================= -# 01_basic_agent.py, 02_tool_reporting.py +# 01_basic_agent.py, 02_tool_reporting.py, 04_memory_secretary.py, +# 05_agno_db_history.py agno_agent: agent_id: "" api_key: "" diff --git a/examples/agno/04_memory_secretary.py b/examples/agno/04_memory_secretary.py new file mode 100644 index 000000000..14a52db30 --- /dev/null +++ b/examples/agno/04_memory_secretary.py @@ -0,0 +1,112 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Agno agent with Band memory tools enabled. + +This example gives an Agno "secretary" agent access to Band memory tools via +``Capability.MEMORY``. The agent can store durable preferences, profile facts, +standing instructions, and reusable project context, then recall them in later +conversations. + +Try prompts like: +- "Remember that I prefer concise status updates." +- "Remember this for the whole organization: our Q3 launch codename is Cedar." +- "What do you remember about my update style?" + +Requires: + - agent_config.yaml in the working directory with an `agno_agent` entry + (copy the repo-root agent_config.yaml.example to agent_config.yaml and + fill in the agno_agent credentials) + - BAND_WS_URL and BAND_REST_URL environment variables (the platform the + agent_config.yaml credentials belong to) + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Run with: + uv run examples/agno/04_memory_secretary.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter +from band.core.types import AdapterFeatures, Capability, Emit + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +SECRETARY_INSTRUCTIONS = ( + "You are a personal secretary who helps the user preserve useful long-term " + "context. Actively look for durable information worth remembering: user " + "preferences, profile details, standing instructions, important project " + "facts, and reusable workflows. When the user shares something durable, use " + "Band memory tools to store it before replying. Use memory sparingly: do not " + "store one-off requests, temporary chat context, or sensitive information " + "unless the user clearly asks you to remember it. When asked what you " + "remember, use Band memory tools to search before answering. Keep responses " + "short." +) + + +def get_required_env(name: str) -> str: + """Return a required environment variable or raise a clear error.""" + value = os.environ.get(name) + if not value: + raise ValueError(f"{name} environment variable is required") + return value + + +def load_environment() -> tuple[str, str]: + """Load env vars, validate credentials, and return (ws_url, rest_url).""" + load_dotenv() + + get_required_env("ANTHROPIC_API_KEY") + ws_url = get_required_env("BAND_WS_URL") + rest_url = get_required_env("BAND_REST_URL") + return ws_url, rest_url + + +async def main() -> None: + ws_url, rest_url = load_environment() + + agno_agent = AgnoAgent( + model=Claude(id=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6")), + instructions=SECRETARY_INSTRUCTIONS, + ) + + adapter = AgnoAdapter( + agno_agent, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + # Useful while learning: memory tool calls are visible as room events. + emit={Emit.EXECUTION}, + ), + ) + + agent = Agent.from_config( + "agno_agent", + adapter=adapter, + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Starting Agno memory secretary...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agno/05_agno_db_history.py b/examples/agno/05_agno_db_history.py new file mode 100644 index 000000000..a2d74a1cc --- /dev/null +++ b/examples/agno/05_agno_db_history.py @@ -0,0 +1,111 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Agno-owned conversation history with a database. + +This example configures Agno to persist and replay prior turns itself by using +``db=...``, ``session_id=...``, and ``add_history_to_context=True``. When +``AgnoAdapter`` detects this mode, it disables Band history rehydration for the +model input so the same prior turns are not injected twice. + +The example uses Agno's in-memory database so it is easy to run. It preserves +history only while this process is alive. For production, replace ``InMemoryDb`` +with a persistent Agno database and keep the same session-id strategy. + +Try prompts like: +- "Remember that the release checklist lives in Notion page R-42." +- "What checklist page did I mention?" + +Requires: + - agent_config.yaml in the working directory with an `agno_agent` entry + (copy the repo-root agent_config.yaml.example to agent_config.yaml and + fill in the agno_agent credentials) + - BAND_WS_URL and BAND_REST_URL environment variables (the platform the + agent_config.yaml credentials belong to) + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Run with: + uv run examples/agno/05_agno_db_history.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from agno.agent import Agent as AgnoAgent +from agno.db.in_memory import InMemoryDb +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_required_env(name: str) -> str: + """Return a required environment variable or raise a clear error.""" + value = os.environ.get(name) + if not value: + raise ValueError(f"{name} environment variable is required") + return value + + +def load_environment() -> tuple[str, str]: + """Load env vars, validate credentials, and return (ws_url, rest_url).""" + load_dotenv() + + get_required_env("ANTHROPIC_API_KEY") + ws_url = get_required_env("BAND_WS_URL") + rest_url = get_required_env("BAND_REST_URL") + return ws_url, rest_url + + +async def main() -> None: + ws_url, rest_url = load_environment() + + db = InMemoryDb() + session_id = os.environ.get("AGNO_SESSION_ID", "band-agno-db-history") + + agno_agent = AgnoAgent( + model=Claude(id=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6")), + db=db, + session_id=session_id, + add_history_to_context=True, + instructions=( + "You are a helpful assistant with Agno-managed conversation history. " + "When acknowledging or recalling a value the user asked you to " + "remember, include the exact value in your reply. Keep responses " + "short." + ), + ) + + adapter = AgnoAdapter( + agno_agent, + # AgnoAdapter passes session_id on each run. This keeps the example tied + # to the Agno session configured above instead of defaulting to room_id. + session_id_factory=lambda _room_id: session_id, + ) + + agent = Agent.from_config( + "agno_agent", + adapter=adapter, + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Starting Agno DB-history agent (session_id=%s)...", session_id) + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) From df9c147c5f7ffcb85cc401a1462b25e83fe053dd Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:31:49 +0300 Subject: [PATCH 46/90] fix(test): scope e2e fixtures without non-top-level pytest_plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defining pytest_plugins in a non-top-level conftest is a hard error in modern pytest, which broke collection under bare `pytest` (CI). Import the fixture modules into the e2e conftest namespace instead — kept at the bottom of the file since they import E2ESettings/RoomAllocator back from it — so the fixtures stay scoped to tests/e2e/ rather than loading globally. Also wrap an over-length AsyncMock line to satisfy ruff format. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/adapters/agno/test_adapter.py | 4 ++- tests/e2e/conftest.py | 51 +++++++++++++++++++++-------- tests/e2e/fixtures/__init__.py | 2 +- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 7e12ecfa6..de0f9e3c7 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -124,7 +124,9 @@ async def test_factory_built_adapter_runs_the_agent_and_replies(self, tools): # End-to-end through the factory path: the factory's agent must be the # one actually run on a message, and its output delivered to the room. runtime_agent = _factory_agent_stub() - runtime_agent.arun = AsyncMock(return_value=RunOutput(content="hi from factory")) + runtime_agent.arun = AsyncMock( + return_value=RunOutput(content="hi from factory") + ) adapter = AgnoAdapter(agent_factory=lambda: runtime_agent) await adapter.on_started("TestBot", "desc") diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 233735f64..758fb86e1 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -8,11 +8,13 @@ Configuration is loaded from .env.test with E2E-specific overrides from env vars. -Fixtures live in concern-focused plugin modules (loaded via ``pytest_plugins`` -below): ``fixtures.clients`` (config + REST/WS clients), ``fixtures.rooms`` (room -allocation + agent identity), ``fixtures.memory`` (memory toolkit). This module -keeps only what tests import by name — ``E2ESettings``, the ``requires_*`` -markers, and the ``RoomAllocator`` type — plus the collection hook. +Fixtures live in concern-focused modules: ``fixtures.clients`` (config + REST/WS +clients), ``fixtures.rooms`` (room allocation + agent identity), ``fixtures.memory`` +(memory toolkit). They are imported into this conftest's namespace at the bottom +of the file (not via ``pytest_plugins``, which is only honored in the top-level +conftest) so they stay scoped to ``tests/e2e/``. This module also keeps what tests +import by name — ``E2ESettings``, the ``requires_*`` markers, and the +``RoomAllocator`` type — plus the collection hook. """ from __future__ import annotations @@ -34,14 +36,6 @@ logger = logging.getLogger(__name__) -# Fixture plugins, grouped by concern. pytest_plugins must be declared in a -# conftest; listing the modules here keeps each fixture file small and focused. -pytest_plugins = ( - "tests.e2e.fixtures.clients", - "tests.e2e.fixtures.rooms", - "tests.e2e.fixtures.memory", -) - # Async callable: name -> (room_id, user_id, user_name). Shared by room fixtures # and by tests that accept an allocator; defined here so both can import it. RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] @@ -139,3 +133,34 @@ def _check_e2e_status() -> tuple[bool, str]: not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set", ) + + +# ============================================================================= +# Fixture registration +# ============================================================================= +# Imported here (rather than via ``pytest_plugins``, which is only honored in the +# top-level conftest) so the fixtures stay scoped to ``tests/e2e/``. The imports +# live at the bottom because the fixture modules import ``E2ESettings`` and +# ``RoomAllocator`` from this module, which must already be defined above. +from tests.e2e.fixtures.clients import ( # noqa: E402, F401 + api_client, + e2e_config, + e2e_created_room_ids, + e2e_room_summary, + e2e_session_client, + e2e_session_client_2, + e2e_user_client, + ws_client, +) +from tests.e2e.fixtures.memory import memory # noqa: E402, F401 +from tests.e2e.fixtures.rooms import ( # noqa: E402, F401 + adapter_entry, + e2e_adapter_room, + e2e_agent_id, + e2e_agent_info, + e2e_agent_info_2, + e2e_fresh_room_allocator, + e2e_isolation_room_b, + e2e_parlant_room, + e2e_room_allocator, +) diff --git a/tests/e2e/fixtures/__init__.py b/tests/e2e/fixtures/__init__.py index 2f19c75a5..f079e2f84 100644 --- a/tests/e2e/fixtures/__init__.py +++ b/tests/e2e/fixtures/__init__.py @@ -1,4 +1,4 @@ -"""E2E fixture plugins, loaded via ``pytest_plugins`` in ``tests/e2e/conftest.py``. +"""E2E fixture modules, imported into ``tests/e2e/conftest.py``'s namespace. Split by concern: ``clients`` (config + REST/WS clients), ``rooms`` (room allocation + agent identity), ``memory`` (memory-test toolkit). From 6b49464e19b9b2a955360e41ef10047dc9c277dd Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:41:28 +0300 Subject: [PATCH 47/90] Extract E2E settings module --- tests/e2e/adapters/conftest.py | 2 +- tests/e2e/adapters/test_agno_memory.py | 2 +- tests/e2e/adapters/test_all_adapters.py | 2 +- tests/e2e/adapters/test_langgraph_memory.py | 2 +- tests/e2e/adapters/test_parlant.py | 2 +- .../test_three_agent_orchestration.py | 2 +- tests/e2e/conftest.py | 164 ++++-------------- tests/e2e/fixtures/clients.py | 2 +- tests/e2e/fixtures/memory.py | 2 +- tests/e2e/fixtures/rooms.py | 2 +- tests/e2e/helpers/agent.py | 2 +- tests/e2e/scenarios/agno/conftest.py | 2 +- .../scenarios/agno/test_database_restart.py | 2 +- tests/e2e/scenarios/agno/test_multi_agent.py | 2 +- tests/e2e/scenarios/agno/test_thoughts.py | 2 +- .../e2e/scenarios/test_context_persistence.py | 2 +- .../test_langgraph_restart_rehydration.py | 2 +- tests/e2e/scenarios/test_room_isolation.py | 2 +- tests/e2e/settings.py | 108 ++++++++++++ 19 files changed, 160 insertions(+), 146 deletions(-) create mode 100644 tests/e2e/settings.py diff --git a/tests/e2e/adapters/conftest.py b/tests/e2e/adapters/conftest.py index 69a8cbdaf..9a426c449 100644 --- a/tests/e2e/adapters/conftest.py +++ b/tests/e2e/adapters/conftest.py @@ -19,7 +19,7 @@ from band.core.simple_adapter import SimpleAdapter -from tests.e2e.conftest import E2ESettings +from tests.e2e.settings import E2ESettings logger = logging.getLogger(__name__) diff --git a/tests/e2e/adapters/test_agno_memory.py b/tests/e2e/adapters/test_agno_memory.py index 79ebb8a27..87623f687 100644 --- a/tests/e2e/adapters/test_agno_memory.py +++ b/tests/e2e/adapters/test_agno_memory.py @@ -25,7 +25,7 @@ from band import Agent from band.core.types import AdapterFeatures, Capability, Emit -from tests.e2e.conftest import ( +from tests.e2e.settings import ( E2ESettings, RoomAllocator, requires_e2e, diff --git a/tests/e2e/adapters/test_all_adapters.py b/tests/e2e/adapters/test_all_adapters.py index 82c4ce19b..73d27ac3a 100644 --- a/tests/e2e/adapters/test_all_adapters.py +++ b/tests/e2e/adapters/test_all_adapters.py @@ -24,7 +24,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, run_smoke_test, diff --git a/tests/e2e/adapters/test_langgraph_memory.py b/tests/e2e/adapters/test_langgraph_memory.py index 4e8ac2978..e9ff2dfc6 100644 --- a/tests/e2e/adapters/test_langgraph_memory.py +++ b/tests/e2e/adapters/test_langgraph_memory.py @@ -14,7 +14,7 @@ from band import Agent from band.adapters.langgraph import LangGraphAdapter from band.core.types import AdapterFeatures, Capability -from tests.e2e.conftest import E2ESettings, requires_e2e, requires_openai +from tests.e2e.settings import E2ESettings, requires_e2e, requires_openai from tests.e2e.helpers import ( MemoryProbe, TrackingWebSocketClient, diff --git a/tests/e2e/adapters/test_parlant.py b/tests/e2e/adapters/test_parlant.py index 7d6a80543..7df71d715 100644 --- a/tests/e2e/adapters/test_parlant.py +++ b/tests/e2e/adapters/test_parlant.py @@ -20,7 +20,7 @@ from band.agent import Agent -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, run_smoke_test, diff --git a/tests/e2e/agentcore/test_three_agent_orchestration.py b/tests/e2e/agentcore/test_three_agent_orchestration.py index 9e5674e50..d4a2b6ba9 100644 --- a/tests/e2e/agentcore/test_three_agent_orchestration.py +++ b/tests/e2e/agentcore/test_three_agent_orchestration.py @@ -36,7 +36,7 @@ from band_rest import AsyncRestClient, CreateMyChatRoomRequestChat from band_rest.types import ParticipantRequest -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_agent_responses, diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 758fb86e1..233bee958 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,44 +1,52 @@ -"""E2E test configuration: settings, skip markers, and plugin registration. +"""E2E test collection hook and fixture registration. E2E tests run adapters against a real Band platform with real (cheap) LLMs. -They verify platform functionality and integration correctness, not LLM output quality. +They verify platform functionality and integration correctness, not LLM output +quality. Run manually only, never in CI/CD: E2E_TESTS_ENABLED=true uv run pytest tests/e2e/ -v -s --no-cov -Configuration is loaded from .env.test with E2E-specific overrides from env vars. - -Fixtures live in concern-focused modules: ``fixtures.clients`` (config + REST/WS -clients), ``fixtures.rooms`` (room allocation + agent identity), ``fixtures.memory`` -(memory toolkit). They are imported into this conftest's namespace at the bottom -of the file (not via ``pytest_plugins``, which is only honored in the top-level -conftest) so they stay scoped to ``tests/e2e/``. This module also keeps what tests -import by name — ``E2ESettings``, the ``requires_*`` markers, and the -``RoomAllocator`` type — plus the collection hook. +Shared settings, skip markers, and types live in ``tests.e2e.settings`` (a plain +module) so fixtures, helpers, and tests import them without importing from a +conftest. Fixtures live in concern-focused modules — ``fixtures.clients`` (config ++ REST/WS clients), ``fixtures.rooms`` (room allocation + agent identity), +``fixtures.memory`` (memory toolkit) — and are imported into this conftest's +namespace below so they stay scoped to ``tests/e2e/`` (``pytest_plugins`` is only +honored in the top-level conftest). """ from __future__ import annotations -import logging -import os -from collections.abc import Awaitable, Callable from pathlib import Path import pytest -from dotenv import load_dotenv -from pydantic import ValidationError -from thenvoi_testing.settings import BaseTestSettings - -# Load .env.test into os.environ so LLM libraries (langchain, anthropic, etc.) -# can pick up OPENAI_API_KEY, ANTHROPIC_API_KEY, and other keys. -_ENV_TEST_PATH = Path(__file__).parent.parent.parent / ".env.test" -load_dotenv(_ENV_TEST_PATH, override=False) -logger = logging.getLogger(__name__) - -# Async callable: name -> (room_id, user_id, user_name). Shared by room fixtures -# and by tests that accept an allocator; defined here so both can import it. -RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] +# Registering fixtures: pytest discovers fixtures imported into a conftest's +# namespace. The fixture modules import only from ``tests.e2e.settings`` (never +# this conftest), so these imports are free of circular dependencies. +from tests.e2e.fixtures.clients import ( # noqa: F401 + api_client, + e2e_config, + e2e_created_room_ids, + e2e_room_summary, + e2e_session_client, + e2e_session_client_2, + e2e_user_client, + ws_client, +) +from tests.e2e.fixtures.memory import memory # noqa: F401 +from tests.e2e.fixtures.rooms import ( # noqa: F401 + adapter_entry, + e2e_adapter_room, + e2e_agent_id, + e2e_agent_info, + e2e_agent_info_2, + e2e_fresh_room_allocator, + e2e_isolation_room_b, + e2e_parlant_room, + e2e_room_allocator, +) def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @@ -62,105 +70,3 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: if Path(item.path).is_relative_to(e2e_dir): item.add_marker(session_marker) item.add_marker(timeout_marker) - - -# ============================================================================= -# E2E Settings -# ============================================================================= - - -class E2ESettings(BaseTestSettings): - """Settings for E2E tests, loaded from .env.test. - - Loads from .env.test and allows E2E-specific overrides via env vars. - Pydantic BaseSettings automatically maps environment variables to fields - (e.g. E2E_LLM_MODEL -> e2e_llm_model) with case-insensitive matching. - """ - - class Config: - env_file = _ENV_TEST_PATH - - band_api_key: str = "" - band_api_key_2: str = "" - band_api_key_user: str = "" - band_base_url: str = "http://localhost:4000" - band_ws_url: str = "ws://localhost:4000/api/v1/socket/websocket" - test_agent_id: str = "" - test_agent_id_2: str = "" - - # E2E-specific settings (override via environment variables) - e2e_llm_model: str = "gpt-5.4-mini" - e2e_anthropic_model: str = "claude-haiku-4-5-20251001" - e2e_timeout: int = 30 - e2e_tests_enabled: bool = False - - -# ============================================================================= -# Skip Markers -# ============================================================================= - - -def _check_e2e_status() -> tuple[bool, str]: - """Check if E2E tests should be skipped. - - Evaluated once at module import time (when the ``requires_e2e`` marker - is created). Returns ``(is_disabled, reason)`` so the skip message is - actionable. - """ - try: - settings = E2ESettings() - if not settings.e2e_tests_enabled: - return True, "E2E_TESTS_ENABLED is not set to true" - if not settings.band_api_key: - return True, "BAND_API_KEY is not set" - return False, "E2E tests enabled" - except (ValidationError, ValueError, OSError) as exc: - logger.warning( - "E2E settings could not be loaded (missing .env.test?), skipping E2E tests", - exc_info=True, - ) - return True, f"E2E settings could not be loaded: {exc}" - - -_e2e_is_disabled, _e2e_skip_reason = _check_e2e_status() - -requires_e2e = pytest.mark.skipif( - _e2e_is_disabled, - reason=_e2e_skip_reason or "E2E tests disabled", -) - -requires_openai = pytest.mark.skipif( - not os.environ.get("OPENAI_API_KEY"), - reason="OPENAI_API_KEY not set", -) - - -# ============================================================================= -# Fixture registration -# ============================================================================= -# Imported here (rather than via ``pytest_plugins``, which is only honored in the -# top-level conftest) so the fixtures stay scoped to ``tests/e2e/``. The imports -# live at the bottom because the fixture modules import ``E2ESettings`` and -# ``RoomAllocator`` from this module, which must already be defined above. -from tests.e2e.fixtures.clients import ( # noqa: E402, F401 - api_client, - e2e_config, - e2e_created_room_ids, - e2e_room_summary, - e2e_session_client, - e2e_session_client_2, - e2e_user_client, - ws_client, -) -from tests.e2e.fixtures.memory import memory # noqa: E402, F401 -from tests.e2e.fixtures.rooms import ( # noqa: E402, F401 - adapter_entry, - e2e_adapter_room, - e2e_agent_id, - e2e_agent_info, - e2e_agent_info_2, - e2e_fresh_room_allocator, - e2e_isolation_room_b, - e2e_parlant_room, - e2e_room_allocator, -) diff --git a/tests/e2e/fixtures/clients.py b/tests/e2e/fixtures/clients.py index 4250d0bef..434bdbb05 100644 --- a/tests/e2e/fixtures/clients.py +++ b/tests/e2e/fixtures/clients.py @@ -15,7 +15,7 @@ from band.client.streaming import WebSocketClient -from tests.e2e.conftest import E2ESettings +from tests.e2e.settings import E2ESettings from tests.e2e.helpers import TrackingWebSocketClient logger = logging.getLogger(__name__) diff --git a/tests/e2e/fixtures/memory.py b/tests/e2e/fixtures/memory.py index aa43a4a3c..5c2848f26 100644 --- a/tests/e2e/fixtures/memory.py +++ b/tests/e2e/fixtures/memory.py @@ -8,7 +8,7 @@ from band_rest import AsyncRestClient from tests.conftest_integration import is_no_clean_mode -from tests.e2e.conftest import E2ESettings +from tests.e2e.settings import E2ESettings from tests.e2e.helpers import MemoryProbe diff --git a/tests/e2e/fixtures/rooms.py b/tests/e2e/fixtures/rooms.py index 3e017de0a..ab2fee0a3 100644 --- a/tests/e2e/fixtures/rooms.py +++ b/tests/e2e/fixtures/rooms.py @@ -18,7 +18,7 @@ from band_rest.types import ParticipantRequest from tests.conftest_integration import is_no_clean_mode, is_room_alive -from tests.e2e.conftest import RoomAllocator +from tests.e2e.settings import RoomAllocator if TYPE_CHECKING: from tests.e2e.adapters.conftest import AdapterFactory diff --git a/tests/e2e/helpers/agent.py b/tests/e2e/helpers/agent.py index 4d5ba70e4..078dc75e5 100644 --- a/tests/e2e/helpers/agent.py +++ b/tests/e2e/helpers/agent.py @@ -18,7 +18,7 @@ from tests.e2e.helpers.log import log_step if TYPE_CHECKING: - from tests.e2e.conftest import E2ESettings + from tests.e2e.settings import E2ESettings # The platform rate-limits how often one agent_id may reopen its WebSocket after # a recent supersede (HTTP 429); a fresh agent is built per attempt so a partial diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index a12bdc22b..a63dbeebf 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -29,7 +29,7 @@ from tests.conftest_integration import fetch_all_context from tests.e2e.adapters.conftest import _require_anthropic_key -from tests.e2e.conftest import E2ESettings, RoomAllocator +from tests.e2e.settings import E2ESettings, RoomAllocator from tests.e2e.helpers import find_tool_call_in_context, log_step if TYPE_CHECKING: diff --git a/tests/e2e/scenarios/agno/test_database_restart.py b/tests/e2e/scenarios/agno/test_database_restart.py index e831e8b4d..c638832e2 100644 --- a/tests/e2e/scenarios/agno/test_database_restart.py +++ b/tests/e2e/scenarios/agno/test_database_restart.py @@ -39,7 +39,7 @@ from band_rest import AsyncRestClient from tests.conftest_integration import fetch_all_context -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py index 3a2a6670f..346b3305d 100644 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -30,7 +30,7 @@ import pytest from band_rest import AsyncRestClient -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_room_activity, diff --git a/tests/e2e/scenarios/agno/test_thoughts.py b/tests/e2e/scenarios/agno/test_thoughts.py index cd07a2438..09922a618 100644 --- a/tests/e2e/scenarios/agno/test_thoughts.py +++ b/tests/e2e/scenarios/agno/test_thoughts.py @@ -24,7 +24,7 @@ import pytest from band_rest import AsyncRestClient -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_room_activity, diff --git a/tests/e2e/scenarios/test_context_persistence.py b/tests/e2e/scenarios/test_context_persistence.py index 43af3a2ea..3e8f40360 100644 --- a/tests/e2e/scenarios/test_context_persistence.py +++ b/tests/e2e/scenarios/test_context_persistence.py @@ -23,7 +23,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, diff --git a/tests/e2e/scenarios/test_langgraph_restart_rehydration.py b/tests/e2e/scenarios/test_langgraph_restart_rehydration.py index 1579aef2c..089d273fc 100644 --- a/tests/e2e/scenarios/test_langgraph_restart_rehydration.py +++ b/tests/e2e/scenarios/test_langgraph_restart_rehydration.py @@ -33,7 +33,7 @@ from band import Agent from band.adapters import LangGraphAdapter from band.client.streaming import MessageCreatedPayload, WebSocketClient -from tests.e2e.conftest import requires_e2e, requires_openai +from tests.e2e.settings import requires_e2e, requires_openai logger = logging.getLogger(__name__) diff --git a/tests/e2e/scenarios/test_room_isolation.py b/tests/e2e/scenarios/test_room_isolation.py index 6ab837b3f..bbd48568a 100644 --- a/tests/e2e/scenarios/test_room_isolation.py +++ b/tests/e2e/scenarios/test_room_isolation.py @@ -23,7 +23,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.conftest import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, diff --git a/tests/e2e/settings.py b/tests/e2e/settings.py new file mode 100644 index 000000000..dfb9319a7 --- /dev/null +++ b/tests/e2e/settings.py @@ -0,0 +1,108 @@ +"""Shared E2E settings, skip markers, and types. + +Lives in a plain module (not ``conftest.py``) so fixtures, helpers, and tests can +import these symbols without importing from a conftest — which couples modules to +pytest's collection machinery and invites circular imports. ``conftest.py`` holds +only hooks and fixture registration. + +Configuration is loaded from ``.env.test`` with E2E-specific overrides from env +vars. E2E tests run adapters against a real Band platform with real (cheap) LLMs; +they verify platform/integration correctness, not LLM output quality, and run +manually only (never in CI): + + E2E_TESTS_ENABLED=true uv run pytest tests/e2e/ -v -s --no-cov +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Awaitable, Callable +from pathlib import Path + +import pytest +from dotenv import load_dotenv +from pydantic import ValidationError +from thenvoi_testing.settings import BaseTestSettings + +# Load .env.test into os.environ so LLM libraries (langchain, anthropic, etc.) +# can pick up OPENAI_API_KEY, ANTHROPIC_API_KEY, and other keys. +_ENV_TEST_PATH = Path(__file__).parent.parent.parent / ".env.test" +load_dotenv(_ENV_TEST_PATH, override=False) + +logger = logging.getLogger(__name__) + +# Async callable: name -> (room_id, user_id, user_name). Shared by room fixtures +# and by tests that accept an allocator; defined here so both can import it. +RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] + + +# ============================================================================= +# E2E Settings +# ============================================================================= + + +class E2ESettings(BaseTestSettings): + """Settings for E2E tests, loaded from .env.test. + + Loads from .env.test and allows E2E-specific overrides via env vars. + Pydantic BaseSettings automatically maps environment variables to fields + (e.g. E2E_LLM_MODEL -> e2e_llm_model) with case-insensitive matching. + """ + + class Config: + env_file = _ENV_TEST_PATH + + band_api_key: str = "" + band_api_key_2: str = "" + band_api_key_user: str = "" + band_base_url: str = "http://localhost:4000" + band_ws_url: str = "ws://localhost:4000/api/v1/socket/websocket" + test_agent_id: str = "" + test_agent_id_2: str = "" + + # E2E-specific settings (override via environment variables) + e2e_llm_model: str = "gpt-5.4-mini" + e2e_anthropic_model: str = "claude-haiku-4-5-20251001" + e2e_timeout: int = 30 + e2e_tests_enabled: bool = False + + +# ============================================================================= +# Skip Markers +# ============================================================================= + + +def _check_e2e_status() -> tuple[bool, str]: + """Check if E2E tests should be skipped. + + Evaluated once at module import time (when the ``requires_e2e`` marker + is created). Returns ``(is_disabled, reason)`` so the skip message is + actionable. + """ + try: + settings = E2ESettings() + if not settings.e2e_tests_enabled: + return True, "E2E_TESTS_ENABLED is not set to true" + if not settings.band_api_key: + return True, "BAND_API_KEY is not set" + return False, "E2E tests enabled" + except (ValidationError, ValueError, OSError) as exc: + logger.warning( + "E2E settings could not be loaded (missing .env.test?), skipping E2E tests", + exc_info=True, + ) + return True, f"E2E settings could not be loaded: {exc}" + + +_e2e_is_disabled, _e2e_skip_reason = _check_e2e_status() + +requires_e2e = pytest.mark.skipif( + _e2e_is_disabled, + reason=_e2e_skip_reason or "E2E tests disabled", +) + +requires_openai = pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set", +) From d092d3f9ecbdd5dc3c81e504a9ed191c85cd9213 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:42:59 +0300 Subject: [PATCH 48/90] fix(test): assert Agno history guard after startup in e2e restart History detection moved to on_started (the agent_factory change), so _agno_manages_history is only True once the agent has started. Move both assertions inside the running_agent context, where on_started has run. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/scenarios/agno/test_database_restart.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/scenarios/agno/test_database_restart.py b/tests/e2e/scenarios/agno/test_database_restart.py index c638832e2..37638c0fb 100644 --- a/tests/e2e/scenarios/agno/test_database_restart.py +++ b/tests/e2e/scenarios/agno/test_database_restart.py @@ -85,8 +85,6 @@ async def test_db_backed_agent_remembers_after_restart( # --- Phase 1: start the agent and have it store the secret --- log_step(1, f"starting db-backed Agno agent (room {room_id})") adapter = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) - # Guard engaged: the adapter has disabled Band's history rehydration. - assert adapter._agno_manages_history is True async with running_agent( adapter, @@ -94,6 +92,9 @@ async def test_db_backed_agent_remembers_after_restart( api_key=e2e_config.band_api_key, config=e2e_config, ): + # Guard engaged after startup: detection runs in on_started, so by + # now the adapter has disabled Band's history rehydration. + assert adapter._agno_manages_history is True log_step(2, f"user asks the agent to remember {secret_code}") async with listening_for_room_activity( ws_client, @@ -116,7 +117,6 @@ async def test_db_backed_agent_remembers_after_restart( # --- Phase 2: reboot (fresh instance, same db + session) and recall --- log_step("restart", "agent stopped; rebooting with the same db + session_id") adapter2 = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) - assert adapter2._agno_manages_history is True async with running_agent( adapter2, @@ -124,6 +124,7 @@ async def test_db_backed_agent_remembers_after_restart( api_key=e2e_config.band_api_key, config=e2e_config, ): + assert adapter2._agno_manages_history is True log_step(3, "user asks the rebooted agent to recall the code") async with listening_for_room_activity( ws_client, From b8d6c255a0ec73372abc3bc162217177a5160095 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 15:50:48 +0300 Subject: [PATCH 49/90] fix(agno): strip numeric-range keywords from Band tool schemas Band pagination params declare bounds via Pydantic Field(ge=..., le=...), which render as JSON-Schema minimum/maximum. Agno forwards Band's schemas to whatever model backs the agent, and an Anthropic-backed Agno agent rejects maximum on integer params ("For 'integer' type, property 'maximum' is not supported"), failing the whole run before any tool call. Strip minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf from each wired tool's parameter schema (mirrors Google ADK stripping additionalProperties). The bounds are still enforced locally when tool-call args are validated against the Pydantic models, so no real guardrail is lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 33 +++++++++++++++- tests/adapters/agno/test_adapter.py | 61 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index a14a8adde..fe4cabd59 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -53,6 +53,34 @@ "agno_current_tools", default=None ) +# JSON-Schema numeric-range keywords that some providers reject on tool parameter +# schemas. Agno is model-agnostic and forwards Band's schemas to whatever model +# backs the agent; an Anthropic-backed Agno agent rejects ``maximum`` on integer +# params ("For 'integer' type, property 'maximum' is not supported"). Band's +# pagination params carry these via Pydantic ``Field(ge=..., le=...)``. The bounds +# are still enforced locally when tool-call arguments are validated, so dropping +# them from the advertised schema loses no real guardrail. +_UNSUPPORTED_SCHEMA_KEYS = frozenset( + {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"} +) + + +def _strip_numeric_constraints(schema: Any) -> Any: + """Recursively drop numeric-range keywords from a JSON-Schema structure. + + Returns a new structure; the input is left untouched. Mirrors the schema + sanitizing other adapters do (e.g. Google ADK strips ``additionalProperties``). + """ + if isinstance(schema, list): + return [_strip_numeric_constraints(item) for item in schema] + if not isinstance(schema, dict): + return schema + return { + key: _strip_numeric_constraints(value) + for key, value in schema.items() + if key not in _UNSUPPORTED_SCHEMA_KEYS + } + def _tool_executions(response: RunOutput) -> list[Any]: return list(getattr(response, "tools", None) or []) @@ -544,8 +572,9 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: function_cls( name=name, description=fn.get("description", "") or "", - parameters=fn.get("parameters") - or {"type": "object", "properties": {}}, + parameters=_strip_numeric_constraints( + fn.get("parameters") or {"type": "object", "properties": {}} + ), entrypoint=_make_band_entrypoint(name), skip_entrypoint_processing=True, ) diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index de0f9e3c7..f4776b6e9 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -23,6 +23,7 @@ AgnoAdapter, _bind_room_tools, _make_band_entrypoint, + _strip_numeric_constraints, ) from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage from band.testing import FakeAgentTools @@ -769,6 +770,66 @@ async def test_contact_tools_added_additively_after_hub(self, make_started_adapt assert copy.arun.await_count == 2 +class TestSchemaSanitization: + """Band pagination params carry numeric-range keywords (Pydantic ge/le -> + minimum/maximum); some providers reject those on integers, so the adapter + strips them before wiring tools into Agno.""" + + def test_strip_removes_range_keywords_recursively(self): + schema = { + "type": "object", + "properties": { + "page": {"type": "integer", "minimum": 1}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 100}, + "name": {"type": "string"}, + }, + } + + cleaned = _strip_numeric_constraints(schema) + + assert cleaned["properties"]["page"] == {"type": "integer"} + assert cleaned["properties"]["page_size"] == {"type": "integer"} + assert cleaned["properties"]["name"] == {"type": "string"} + # The input schema is left untouched (a new structure is returned). + assert "maximum" in schema["properties"]["page_size"] + + async def test_wired_tool_schema_has_no_numeric_constraints( + self, make_started_adapter + ): + schema = { + "type": "function", + "function": { + "name": "band_lookup_peers", + "description": "lookup peers", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + }, + }, + }, + }, + } + adapter, copy = await make_started_adapter() + + await adapter.on_message( + _msg("room-1", "hi"), + SchemaTools([schema]), + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + wired = copy.add_tool.call_args_list[0].args[0] + page_size = wired.parameters["properties"]["page_size"] + assert page_size == {"type": "integer"} + + class TestFeatureFilters: """AdapterFeatures include/exclude/category filters gate which Band tools are wired (parity with LangGraph).""" From cd6b36196ad0275886d5f9dcd84b6339ac520c8a Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 16:08:54 +0300 Subject: [PATCH 50/90] refactor(tools): centralize tool-schema sanitizing, strip numeric bounds at source Replaces the per-adapter scrubbers (agno._strip_numeric_constraints, google_adk._strip_additional_properties) with one shared, keyword-aware sanitize_tool_schema in core/tool_filter. - Part A: drop numeric-range keywords (minimum/maximum/exclusive*/multipleOf) in get_tool_schemas() so every schema consumer is fixed at once; bounds stay enforced at execution via model_validate. - Part B: google_adk and gemini now call the shared helper (numeric bounds + additionalProperties); the shared helper is keyword-aware so a tool param literally named "maximum" survives (the old strippers would have dropped it). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 33 +------- src/band/adapters/gemini.py | 10 ++- src/band/adapters/google_adk.py | 38 ++------- src/band/core/tool_filter.py | 66 +++++++++++++++- src/band/runtime/tools.py | 6 ++ tests/adapters/agno/test_adapter.py | 61 --------------- tests/adapters/test_gemini_adapter.py | 41 ++++++++++ tests/adapters/test_google_adk_adapter.py | 53 ------------- tests/core/test_tool_filter.py | 93 ++++++++++++++++++++++- tests/runtime/test_tools.py | 17 +++++ 10 files changed, 236 insertions(+), 182 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index fe4cabd59..a14a8adde 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -53,34 +53,6 @@ "agno_current_tools", default=None ) -# JSON-Schema numeric-range keywords that some providers reject on tool parameter -# schemas. Agno is model-agnostic and forwards Band's schemas to whatever model -# backs the agent; an Anthropic-backed Agno agent rejects ``maximum`` on integer -# params ("For 'integer' type, property 'maximum' is not supported"). Band's -# pagination params carry these via Pydantic ``Field(ge=..., le=...)``. The bounds -# are still enforced locally when tool-call arguments are validated, so dropping -# them from the advertised schema loses no real guardrail. -_UNSUPPORTED_SCHEMA_KEYS = frozenset( - {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"} -) - - -def _strip_numeric_constraints(schema: Any) -> Any: - """Recursively drop numeric-range keywords from a JSON-Schema structure. - - Returns a new structure; the input is left untouched. Mirrors the schema - sanitizing other adapters do (e.g. Google ADK strips ``additionalProperties``). - """ - if isinstance(schema, list): - return [_strip_numeric_constraints(item) for item in schema] - if not isinstance(schema, dict): - return schema - return { - key: _strip_numeric_constraints(value) - for key, value in schema.items() - if key not in _UNSUPPORTED_SCHEMA_KEYS - } - def _tool_executions(response: RunOutput) -> list[Any]: return list(getattr(response, "tools", None) or []) @@ -572,9 +544,8 @@ def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: function_cls( name=name, description=fn.get("description", "") or "", - parameters=_strip_numeric_constraints( - fn.get("parameters") or {"type": "object", "properties": {}} - ), + parameters=fn.get("parameters") + or {"type": "object", "properties": {}}, entrypoint=_make_band_entrypoint(name), skip_entrypoint_processing=True, ) diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index a0f974efd..db4158360 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -25,6 +25,7 @@ from band.core.exceptions import BandConfigError from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter +from band.core.tool_filter import sanitize_tool_schema from band.core.types import ( AdapterFeatures, Capability, @@ -413,8 +414,10 @@ def _build_gemini_tools(self, tools: AgentToolsProtocol) -> list[types.Tool]: name = function.get("name") if not name: continue - parameters = function.get( - "parameters", {"type": "object", "properties": {}} + parameters = sanitize_tool_schema( + function.get("parameters", {"type": "object", "properties": {}}), + drop_numeric_bounds=True, + drop_additional_properties=True, ) declarations.append( types.FunctionDeclaration( @@ -427,6 +430,9 @@ def _build_gemini_tools(self, tools: AgentToolsProtocol) -> list[types.Tool]: for input_model, _func in self._custom_tools: schema = input_model.model_json_schema() schema.pop("title", None) + schema = sanitize_tool_schema( + schema, drop_numeric_bounds=True, drop_additional_properties=True + ) tool_name = get_custom_tool_name(input_model) declarations.append( types.FunctionDeclaration( diff --git a/src/band/adapters/google_adk.py b/src/band/adapters/google_adk.py index c445305c6..a4b95f1dc 100644 --- a/src/band/adapters/google_adk.py +++ b/src/band/adapters/google_adk.py @@ -21,6 +21,7 @@ from band.core.exceptions import BandConfigError from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter +from band.core.tool_filter import sanitize_tool_schema from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage from band.converters.google_adk import GoogleADKHistoryConverter, GoogleADKMessages from band.runtime.custom_tools import ( @@ -91,37 +92,6 @@ def _require_adk() -> tuple[type, type, type, Any]: return ADKAgent, InMemoryRunner, BaseTool, types -def _strip_additional_properties( - openai_params: dict[str, Any] | list[Any] | Any, -) -> Any: - """Convert OpenAI JSON Schema parameters to Gemini format. - - Gemini does not support the ``additionalProperties`` key in function - parameter schemas. Passing it causes ``google.genai`` to reject the - declaration with a validation error. This helper strips the key - recursively so the schema is compatible. - """ - if isinstance(openai_params, list): - return [ - _strip_additional_properties(item) - if isinstance(item, (dict, list)) - else item - for item in openai_params - ] - if not isinstance(openai_params, dict): - return openai_params - - cleaned: dict[str, Any] = {} - for key, value in openai_params.items(): - if key == "additionalProperties": - continue - if isinstance(value, (dict, list)): - cleaned[key] = _strip_additional_properties(value) - else: - cleaned[key] = value - return cleaned - - @functools.lru_cache(maxsize=1) def _get_tool_bridge_class() -> type: """Build the ``_BandToolBridge`` class lazily. @@ -193,7 +163,11 @@ def __init__( self._cached_declaration = types.FunctionDeclaration( name=tool_name, description=tool_description, - parameters=_strip_additional_properties(parameters_schema), + parameters=sanitize_tool_schema( + parameters_schema, + drop_numeric_bounds=True, + drop_additional_properties=True, + ), ) except Exception as exc: raise RuntimeError( diff --git a/src/band/core/tool_filter.py b/src/band/core/tool_filter.py index 98461bf16..8e39eb6c2 100644 --- a/src/band/core/tool_filter.py +++ b/src/band/core/tool_filter.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Callable, TypeVar +from typing import Any, Callable, TypeVar from band.core.types import AdapterFeatures @@ -15,6 +15,70 @@ T = TypeVar("T") +# Numeric-range JSON-Schema keywords. Some providers reject these on integer or +# number parameters in tool/function schemas (e.g. Gemini, and Anthropic-backed +# Agno: "For 'integer' type, property 'maximum' is not supported"). +_NUMERIC_BOUND_KEYWORDS = frozenset( + {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"} +) + +# JSON-Schema keywords whose values are maps of *arbitrary property names* to +# subschemas. Their child keys are names, not keywords, so they must never be +# stripped (a tool param literally named ``maximum`` must survive). +_NAME_MAP_KEYWORDS = frozenset( + {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} +) + + +def sanitize_tool_schema( + schema: Any, + *, + drop_numeric_bounds: bool = False, + drop_additional_properties: bool = False, +) -> Any: + """Recursively remove JSON-Schema keywords that some providers reject. + + Returns a new structure; the input is left untouched. Centralizes the + schema scrubbing that model adapters need before handing Band tool schemas + to a provider that rejects otherwise-valid JSON Schema. + + Args: + schema: A JSON-Schema dict (or any nested fragment of one). + drop_numeric_bounds: Drop ``minimum``/``maximum``/``exclusiveMinimum``/ + ``exclusiveMaximum``/``multipleOf``. The bounds remain enforced + wherever tool-call arguments are validated against the source model. + drop_additional_properties: Drop ``additionalProperties`` (rejected by + Gemini). + + Keys are stripped only where they act as schema keywords, never where they + are property names under ``properties``/``$defs``/etc. + """ + drop: set[str] = set() + if drop_numeric_bounds: + drop |= _NUMERIC_BOUND_KEYWORDS + if drop_additional_properties: + drop.add("additionalProperties") + return _sanitize(schema, drop) + + +def _sanitize(node: Any, drop: frozenset[str] | set[str]) -> Any: + if isinstance(node, list): + return [_sanitize(item, drop) for item in node] + if not isinstance(node, dict): + return node + cleaned: dict[str, Any] = {} + for key, value in node.items(): + if key in drop: + continue + if key in _NAME_MAP_KEYWORDS and isinstance(value, dict): + # Values here are name -> subschema; keep names, scrub each subschema. + cleaned[key] = { + name: _sanitize(subschema, drop) for name, subschema in value.items() + } + else: + cleaned[key] = _sanitize(value, drop) + return cleaned + def filter_tool_schemas( schemas: list[T], diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index 60f7e2ae0..097adfecb 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -28,6 +28,7 @@ validate_subject_scope, ) from band.core.protocols import AgentToolsProtocol +from band.core.tool_filter import sanitize_tool_schema if TYPE_CHECKING: from anthropic.types import ToolParam @@ -2087,6 +2088,11 @@ def get_tool_schemas( schema = definition.input_model.model_json_schema() # Remove Pydantic-specific keys schema.pop("title", None) + # Pydantic Field(ge=..., le=...) renders as JSON-Schema minimum/maximum, + # which some providers reject on integer params (e.g. Gemini, and + # Anthropic-backed Agno). The bounds stay enforced at execution via + # model_validate, so drop them from the advertised schema. + schema = sanitize_tool_schema(schema, drop_numeric_bounds=True) if format == "openai": tools.append( diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index f4776b6e9..de0f9e3c7 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -23,7 +23,6 @@ AgnoAdapter, _bind_room_tools, _make_band_entrypoint, - _strip_numeric_constraints, ) from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage from band.testing import FakeAgentTools @@ -770,66 +769,6 @@ async def test_contact_tools_added_additively_after_hub(self, make_started_adapt assert copy.arun.await_count == 2 -class TestSchemaSanitization: - """Band pagination params carry numeric-range keywords (Pydantic ge/le -> - minimum/maximum); some providers reject those on integers, so the adapter - strips them before wiring tools into Agno.""" - - def test_strip_removes_range_keywords_recursively(self): - schema = { - "type": "object", - "properties": { - "page": {"type": "integer", "minimum": 1}, - "page_size": {"type": "integer", "minimum": 1, "maximum": 100}, - "name": {"type": "string"}, - }, - } - - cleaned = _strip_numeric_constraints(schema) - - assert cleaned["properties"]["page"] == {"type": "integer"} - assert cleaned["properties"]["page_size"] == {"type": "integer"} - assert cleaned["properties"]["name"] == {"type": "string"} - # The input schema is left untouched (a new structure is returned). - assert "maximum" in schema["properties"]["page_size"] - - async def test_wired_tool_schema_has_no_numeric_constraints( - self, make_started_adapter - ): - schema = { - "type": "function", - "function": { - "name": "band_lookup_peers", - "description": "lookup peers", - "parameters": { - "type": "object", - "properties": { - "page_size": { - "type": "integer", - "minimum": 1, - "maximum": 100, - }, - }, - }, - }, - } - adapter, copy = await make_started_adapter() - - await adapter.on_message( - _msg("room-1", "hi"), - SchemaTools([schema]), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - wired = copy.add_tool.call_args_list[0].args[0] - page_size = wired.parameters["properties"]["page_size"] - assert page_size == {"type": "integer"} - - class TestFeatureFilters: """AdapterFeatures include/exclude/category filters gate which Band tools are wired (parity with LangGraph).""" diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index 9a3957305..17dfb761c 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -232,6 +232,47 @@ async def test_retries_transient_server_errors(self): assert response.candidates[0].content.parts[0].text == "ok" +class TestBuildGeminiTools: + """Gemini rejects numeric bounds and additionalProperties on tool params, so + the adapter must sanitize Band schemas before building declarations.""" + + def test_declarations_drop_numeric_bounds_and_additional_properties( + self, mock_tools + ): + mock_tools.get_openai_tool_schemas = MagicMock( + return_value=[ + { + "type": "function", + "function": { + "name": "band_lookup_peers", + "description": "lookup peers", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + }, + }, + "additionalProperties": False, + }, + }, + } + ] + ) + adapter = GeminiAdapter(provider_key="test-key") + + tools = adapter._build_gemini_tools(mock_tools) + + decl = tools[0].function_declarations[0] + schema = decl.parameters_json_schema + assert "additionalProperties" not in schema + page_size = schema["properties"]["page_size"] + assert "minimum" not in page_size + assert "maximum" not in page_size + + class TestCustomTools: @pytest.mark.asyncio async def test_executes_custom_tool(self, mock_tools): diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 21495e03c..6ebcbb7f0 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -26,7 +26,6 @@ _get_tool_bridge_class = _google_adk_mod._get_tool_bridge_class _BandToolBridge = _get_tool_bridge_class() _sanitize_adk_agent_name = _google_adk_mod._sanitize_adk_agent_name -_strip_additional_properties = _google_adk_mod._strip_additional_properties @pytest.fixture @@ -526,58 +525,6 @@ def test_smoke_test_runs_at_class_creation(self): assert decl.name == "smoke" -class TestStripAdditionalProperties: - """Tests for _strip_additional_properties module-level function.""" - - def test_strips_additional_properties(self): - """Should strip additionalProperties from schema for Gemini compatibility.""" - schema = { - "type": "object", - "properties": { - "name": {"type": "string"}, - "nested": { - "type": "object", - "properties": {"x": {"type": "integer"}}, - "additionalProperties": False, - }, - }, - "additionalProperties": False, - "required": ["name"], - } - - cleaned = _strip_additional_properties(schema) - - assert "additionalProperties" not in cleaned - assert "additionalProperties" not in cleaned["properties"]["nested"] - assert cleaned["properties"]["name"] == {"type": "string"} - assert cleaned["required"] == ["name"] - - def test_handles_top_level_list(self): - """Should recurse into top-level list items (e.g. anyOf/oneOf schemas).""" - schema_list = [ - {"type": "string", "additionalProperties": False}, - { - "type": "object", - "properties": {"x": {"type": "integer"}}, - "additionalProperties": False, - }, - ] - - cleaned = _strip_additional_properties(schema_list) - - assert isinstance(cleaned, list) - assert len(cleaned) == 2 - assert "additionalProperties" not in cleaned[0] - assert "additionalProperties" not in cleaned[1] - assert cleaned[1]["properties"]["x"] == {"type": "integer"} - - def test_handles_non_dict_input(self): - """Should return non-dict/non-list input as-is.""" - assert _strip_additional_properties("string") == "string" - assert _strip_additional_properties(42) == 42 - assert _strip_additional_properties(None) is None - - class TestBuildADKTools: """Tests for _build_adk_tools.""" diff --git a/tests/core/test_tool_filter.py b/tests/core/test_tool_filter.py index 504a11470..71a606aee 100644 --- a/tests/core/test_tool_filter.py +++ b/tests/core/test_tool_filter.py @@ -1,4 +1,4 @@ -"""Tests for filter_tool_schemas helper.""" +"""Tests for filter_tool_schemas and sanitize_tool_schema helpers.""" from __future__ import annotations @@ -7,7 +7,7 @@ import pytest -from band.core.tool_filter import filter_tool_schemas +from band.core.tool_filter import filter_tool_schemas, sanitize_tool_schema from band.core.types import AdapterFeatures @@ -110,3 +110,92 @@ def test_category_then_include_precedence_yields_empty(self) -> None: ) # band_store_memory is category "memory", excluded by categories step assert result == [] + + +class TestSanitizeToolSchema: + """sanitize_tool_schema drops provider-incompatible JSON-Schema keywords.""" + + def test_drops_numeric_bounds_recursively(self): + schema = { + "type": "object", + "properties": { + "page": {"type": "integer", "minimum": 1}, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "multipleOf": 1, + }, + "name": {"type": "string"}, + }, + } + + cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) + + assert cleaned["properties"]["page"] == {"type": "integer"} + assert cleaned["properties"]["page_size"] == {"type": "integer"} + assert cleaned["properties"]["name"] == {"type": "string"} + + def test_drops_additional_properties_recursively(self): + schema = { + "type": "object", + "properties": { + "nested": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + } + + cleaned = sanitize_tool_schema(schema, drop_additional_properties=True) + + assert "additionalProperties" not in cleaned + assert "additionalProperties" not in cleaned["properties"]["nested"] + + def test_default_is_a_noop_copy(self): + schema = {"type": "integer", "maximum": 100, "additionalProperties": False} + + cleaned = sanitize_tool_schema(schema) + + assert cleaned == schema + assert cleaned is not schema + + def test_does_not_mutate_input(self): + schema = {"type": "integer", "minimum": 1, "maximum": 100} + + sanitize_tool_schema(schema, drop_numeric_bounds=True) + + assert schema == {"type": "integer", "minimum": 1, "maximum": 100} + + def test_preserves_property_literally_named_maximum(self): + # ``maximum`` as a property *name* (under ``properties``) is a field, not + # the numeric-bound keyword, and must survive the strip. + schema = { + "type": "object", + "properties": { + "maximum": {"type": "integer", "maximum": 10}, + "minimum": {"type": "string"}, + }, + } + + cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) + + assert set(cleaned["properties"]) == {"maximum", "minimum"} + # The bound keyword *inside* the "maximum" field's subschema is stripped. + assert cleaned["properties"]["maximum"] == {"type": "integer"} + + def test_recurses_into_lists_and_returns_non_dicts_as_is(self): + schema = { + "anyOf": [ + {"type": "integer", "maximum": 5}, + {"type": "string"}, + ], + } + + cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) + + assert cleaned == {"anyOf": [{"type": "integer"}, {"type": "string"}]} + assert sanitize_tool_schema("x", drop_numeric_bounds=True) == "x" + assert sanitize_tool_schema(None) is None diff --git a/tests/runtime/test_tools.py b/tests/runtime/test_tools.py index 6fe20aa9b..f83e98f91 100644 --- a/tests/runtime/test_tools.py +++ b/tests/runtime/test_tools.py @@ -971,6 +971,23 @@ def test_get_tool_schemas_anthropic_with_memory(self, mock_rest_client): assert "band_send_message" in tool_names assert "band_list_contacts" in tool_names + def test_schemas_drop_numeric_bounds(self, mock_rest_client): + """Pydantic Field(ge=.., le=..) renders minimum/maximum, which some + providers reject on integer params; the schemas must omit them while the + models still enforce the bounds at execution.""" + tools = AgentTools("room-123", mock_rest_client) + + schemas = tools.get_tool_schemas("openai", include_memory=True) + + page_size = next( + s["function"]["parameters"]["properties"]["page_size"] + for s in schemas + if s["function"]["name"] == "band_lookup_peers" + ) + assert "minimum" not in page_size + assert "maximum" not in page_size + assert page_size["type"] == "integer" + class TestAgentToolsExecuteToolCall: """Test execute_tool_call dispatch.""" From b3a142a486c4e6837a617cdbdacf87791ce5b9af Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 18 Jun 2026 16:36:25 +0300 Subject: [PATCH 51/90] fix(agno): resilient reply mentions; don't fail the turn on unresolvable sender The fallback reply mentioned only msg.sender_id, which can be a different id-space than the cached participants (observed live: "Unknown participant "), raising in mention resolution and marking the inbound message permanently failed. Try sender_id, then fall back to the display name, and degrade to a warning rather than crashing when neither resolves. Validated live: reply now delivered instead of failing the turn. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 38 +++++++++++++++++---- tests/adapters/agno/test_adapter.py | 52 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index a14a8adde..19201b7a1 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -439,15 +439,41 @@ async def _send_reply( logger.debug("Room %s msg %s: agent produced no reply", room_id, msg.id) return - mentions = [msg.sender_id] - logger.info( - "Room %s msg %s: sending reply (%d chars), mentions=%s", + # Address the reply to the sender. ``sender_id`` is the primary + # identifier, but it may not match a cached participant (id-space + # mismatch or a stale cache); fall back to the display name. An + # unresolvable mention raises ValueError in mention resolution, which + # would otherwise fail the whole turn — try each candidate and degrade + # to a warning rather than crashing. + candidates = [c for c in (msg.sender_id, msg.sender_name) if c] + for candidate in candidates: + try: + await tools.send_message(text, mentions=[candidate]) + except ValueError as e: + logger.debug( + "Room %s msg %s: mention %r did not resolve: %s", + room_id, + msg.id, + candidate, + e, + ) + else: + logger.info( + "Room %s msg %s: sent reply (%d chars), mention=%s", + room_id, + msg.id, + len(text), + candidate, + ) + return + + logger.warning( + "Room %s msg %s: no resolvable mention for sender %s (%s); reply not delivered", room_id, msg.id, - len(text), - mentions, + msg.sender_id, + msg.sender_name, ) - await tools.send_message(text, mentions=mentions) def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: """Additively wire this room's Band tools onto the shared agent. diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index de0f9e3c7..176f75f98 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -407,6 +407,58 @@ async def test_no_send_for_empty_content( tools.assert_no_messages_sent() + async def test_reply_falls_back_to_sender_name_when_id_unresolvable( + self, make_started_adapter, sample_platform_message + ): + # sender_id may not match a cached participant (id-space mismatch); the + # reply should retry with the display name rather than failing the turn. + class _IdRejectingTools(FakeAgentTools): + async def send_message(self, content, mentions=None): + if mentions and mentions[0] == sample_platform_message.sender_id: + raise ValueError(f"Unknown participant '{mentions[0]}'") + return await super().send_message(content, mentions=mentions) + + adapter, _ = await make_started_adapter(RunOutput(content="hello")) + tools = _IdRejectingTools() + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_message_sent( + content="hello", mentions=[sample_platform_message.sender_name] + ) + + async def test_reply_does_not_crash_when_no_mention_resolves( + self, make_started_adapter, sample_platform_message + ): + # An unresolvable sender must not fail the whole turn (which would mark + # the inbound message permanently failed). + class _AllRejectingTools(FakeAgentTools): + async def send_message(self, content, mentions=None): + raise ValueError("Unknown participant") + + adapter, _ = await make_started_adapter(RunOutput(content="hello")) + tools = _AllRejectingTools() + + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) + + tools.assert_no_messages_sent() + class TestEmitExecution: async def test_emits_tool_call_and_result_events( From 2cc536b283108bf57e794e75c0d9b2ace42f25c7 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Fri, 19 Jun 2026 09:18:04 +0300 Subject: [PATCH 52/90] fix(test): enable native thinking for Agno thoughts e2e The thinking adapter set reasoning=True but never enabled native extended thinking on the Claude model, so Agno treated it as a non-reasoning model and fell back to a structured-output chain-of-thought agent. That fallback returns empty content for Claude, leaving reasoning_content blank, so the adapter had no reasoning to emit and the thought-event assertion failed. Enable thinking on the Claude model so Agno uses Claude's native thinking output to populate reasoning_content. Validated: test_thoughts now passes and the adapter reports the reasoning as a thought event. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/scenarios/agno/conftest.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index a63dbeebf..869336c8c 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -183,9 +183,14 @@ def build_db_backed_agno_adapter( def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: """Build an Agno adapter with reasoning enabled and thought reporting on. - ``reasoning=True`` makes the Agno agent populate ``reasoning_content`` on - the run output; ``Emit.THOUGHTS`` makes the adapter post that reasoning as - a ``thought`` event to the room. + The Claude model is created with native extended thinking enabled + (``thinking=...``). That makes Agno treat it as a native reasoning model and + populate ``reasoning_content`` from Claude's own thinking output; without it, + Agno falls back to a structured-output chain-of-thought agent that returns + empty content for Claude, leaving ``reasoning_content`` blank and no thought + to emit. ``reasoning=True`` selects the native-reasoning dispatch and + ``Emit.THOUGHTS`` makes the adapter post that reasoning as a ``thought`` + event to the room. """ _require_anthropic_key() from agno.agent import Agent as AgnoAgent @@ -195,7 +200,10 @@ def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: from band.core.types import AdapterFeatures, Emit agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), + model=Claude( + id=settings.e2e_anthropic_model, + thinking={"type": "enabled", "budget_tokens": 1024}, + ), instructions=( "You are a careful assistant. Think through problems step by step " "before answering. Keep your final answer short." From 9fc9d458c5001c3df18796a0f136301399e40773 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Fri, 19 Jun 2026 11:11:20 +0300 Subject: [PATCH 53/90] test(agno): cover context persistence in a dedicated scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared cross-adapter recall test plants a "secret code" in a reused room. Two Agno behaviors break that test's assumptions without being SDK defects: the "code" prompt collides with a standing organization-scoped agent memory phrased as a "code name", and Claude-haiku refuses to act on Band's @[[id]]-formatted rehydrated history (treating it as injected directives). Exclude agno from the shared test with a documented skip, and add a dedicated agno scenario that controls for both effects: a fresh room (no stale content or standing memories), a benign random "lorem" phrase (no "code" collision), an agent told that Band chat formatting is normal and to recall verbatim, and the rate-limit-aware running_agent for the rapid restart. Unlike the db-backed restart test, this agent has no Agno db, so recall must come from Band's history rehydration (is_session_bootstrap) — the behavior under test. Validated passing against the live platform. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agno/test_context_persistence.py | 206 ++++++++++++++++++ .../e2e/scenarios/test_context_persistence.py | 13 ++ 2 files changed, 219 insertions(+) create mode 100644 tests/e2e/scenarios/agno/test_context_persistence.py diff --git a/tests/e2e/scenarios/agno/test_context_persistence.py b/tests/e2e/scenarios/agno/test_context_persistence.py new file mode 100644 index 000000000..4ab474699 --- /dev/null +++ b/tests/e2e/scenarios/agno/test_context_persistence.py @@ -0,0 +1,206 @@ +"""Live: an Agno agent recalls prior conversation from Band history on rejoin. + +The shared cross-adapter recall test +(``tests/e2e/scenarios/test_context_persistence.py``) excludes Agno because two +of its behaviors break that test's assumptions without being SDK defects: + +1. On the shared, reused room a "secret code" prompt collides with standing + organization-scoped agent memories phrased as a "code name", so the agent + recalls the wrong value. +2. The small default agent distrusts Band's ``@[[id]]``-formatted history, + refusing to act on it ("each request stands on its own"). + +This test covers the same capability for Agno specifically while controlling for +both: a **fresh room** (no standing/stale content), a **benign random phrase** +(no "code" collision), an agent **instructed** to treat Band chat formatting as +normal and recall verbatim, and the rate-limit-aware ``running_agent`` for the +rapid restart. + +Unlike ``test_database_restart.py``, this agent has **no Agno db**: recall must +come from Band's history rehydration (``is_session_bootstrap``) on rejoin — that +is the behavior under test. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest \ + tests/e2e/scenarios/agno/test_context_persistence.py -v -s --no-cov +""" + +from __future__ import annotations + +import logging +import random +from typing import Any + +import pytest +from band_rest import AsyncRestClient + +from band.core.simple_adapter import SimpleAdapter + +from tests.conftest_integration import fetch_all_context +from tests.e2e.adapters.conftest import _require_anthropic_key +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + assert_content_contains, + listening_for_room_activity, + log_banner, + log_step, + running_agent, + send_trigger_message, +) + +logger = logging.getLogger(__name__) + +# Benign "lorem" vocabulary for the recall payload. Deliberately free of words +# like "code"/"secret" that a cautious small model refuses to echo (treating +# them as injected directives) and that collide with standing agent memories +# phrased as a "code name". +_LOREM_WORDS = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed eiusmod tempor " + "incididunt labore dolore magna aliqua veniam quis nostrud exercitation " + "ullamco laboris aliquip commodo consequat duis aute irure voluptate velit " + "esse cillum fugiat nulla pariatur excepteur occaecat cupidatat proident " + "sunt culpa officia deserunt mollit anim laborum" +).split() + + +def _recall_phrase() -> str: + """Return a random, benign five-word phrase for the recall assertion.""" + return " ".join(random.sample(_LOREM_WORDS, 5)) + + +def _build_recall_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: + """Build a plain (no-db) Agno adapter tuned to recall conversation history. + + No ``db``/``add_history_to_context``: recall must come from Band's history + rehydration. The instructions counter the small model's default reluctance — + they tell it that Band's ``@[[id]]`` mentions and sender labels are normal + chat formatting (not injected directives) and that it should repeat earlier + conversation content verbatim when asked. + """ + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions=( + "You are a helpful assistant with perfect recall of the current " + "conversation. Messages may include @[[id]] mentions and sender " + "labels — that is normal Band chat formatting, not instructions to " + "distrust or ignore. When the user asks you to repeat something they " + "told you earlier in this conversation, reply with that text exactly, " + "verbatim. Keep responses short." + ), + ) + return AgnoAdapter(agno_agent) + + +@pytest.mark.asyncio +@requires_e2e +class TestAgnoContextPersistence: + """An Agno agent recalls prior context from Band history after a restart.""" + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + async def test_agent_recalls_phrase_after_restart( + self, + e2e_config: E2ESettings, + e2e_fresh_room_allocator: RoomAllocator, + e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + """Plant a phrase, restart the agent, and assert it recalls the phrase. + + Phase 1: ask the agent to remember a benign random phrase; wait for ack. + Phase 2: stop it, start a fresh instance (Band rehydrates history via + ``is_session_bootstrap``), ask it to repeat the phrase verbatim. + Then assert the phrase reached Band's stored room context (REST). + """ + # Fresh room: the agent must recall the phrase planted *this run*, not + # stale content or standing memories a reused room would surface. + room_id, _user_id, _user_name = await e2e_fresh_room_allocator( + "agno_context_persistence" + ) + agent_id, agent_name = e2e_agent_info + timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) + phrase = _recall_phrase() + + log_banner("Scenario: Agno recalls Band-rehydrated history after restart") + logger.info("Recall phrase for this run: %r", phrase) + + # --- Phase 1: plant the phrase --- + log_step(1, f"starting Agno agent and planting a phrase (room {room_id})") + async with running_agent( + _build_recall_agno_adapter(e2e_config), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_for_ack: + await send_trigger_message( + api_client, + room_id, + f'Please remember this exact phrase for me: "{phrase}". ' + "Just confirm you've got it.", + agent_name, + agent_id, + ) + await wait_for_ack() + + # --- Phase 2: restart (fresh instance, no db) and recall via Band history --- + log_step("restart", "agent stopped; starting a fresh instance to recall") + async with running_agent( + _build_recall_agno_adapter(e2e_config), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + log_step(2, "asking the rebooted agent to repeat the phrase") + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_for_recall: + await send_trigger_message( + api_client, + room_id, + "Earlier in this conversation I asked you to remember an exact " + "phrase. Repeat that phrase back to me, word for word.", + agent_name, + agent_id, + ) + phase2_responses = await wait_for_recall() + + assert_content_contains(phase2_responses, phrase) + log_step("assert", "rebooted agent recalled the phrase from Band history") + + # The conversation persisted to Band infra and is retrievable via REST. + log_step(3, "verifying the phrase persisted to Band infra via REST") + items = await fetch_all_context(e2e_session_client, room_id) + texts = [ + getattr(item, "content", "") or "" + for item in items + if getattr(item, "message_type", None) == "text" + ] + assert any(phrase in text for text in texts), ( + f"Expected the phrase {phrase!r} in Band's stored room context, but " + f"it was absent from {len(texts)} text message(s)." + ) + log_step("assert", "phrase persisted to Band infra (REST context)") + + log_banner("Scenario PASSED") diff --git a/tests/e2e/scenarios/test_context_persistence.py b/tests/e2e/scenarios/test_context_persistence.py index 3e8f40360..6fe9e2718 100644 --- a/tests/e2e/scenarios/test_context_persistence.py +++ b/tests/e2e/scenarios/test_context_persistence.py @@ -59,6 +59,19 @@ async def test_agent_remembers_context_after_rejoin( when sharing a room across parametrized runs. """ adapter_name, factory = adapter_entry + # Agno is excluded from this shared "secret code" recall test. Two of its + # behaviors break the test's assumptions in ways that are not SDK defects: + # (1) Claude-haiku refuses to act on Band's @[[id]]-formatted rehydrated + # history, treating it as injected directives ("each request stands on + # its own"); and (2) a "secret code" prompt collides with standing + # organization-scoped agent memories phrased as a "code name", so the + # agent recalls the wrong value. Agno context persistence is covered + # separately in tests/e2e/scenarios/agno/test_context_persistence.py, + # which uses a fresh room and a benign payload to avoid those effects. + if adapter_name == "agno": + pytest.skip( + "agno covered by tests/e2e/scenarios/agno/test_context_persistence.py" + ) chat_id, _user_id, _user_name = e2e_adapter_room agent_id, agent_name = e2e_agent_info timeout = e2e_config.e2e_timeout From 2f29f2e830660c1bd43d5ea90ac80b3c2b98c635 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:44:20 +0000 Subject: [PATCH 54/90] fix(agno): don't mutate committed transcript while building run input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_run_input took a live reference to _message_history[room_id] and appended this turn's injected [System]/user messages in place. The cleanup only happened later in _persist_turn (which reassigns the entry), so when a run raised or returned no messages, those injected messages stayed in the committed transcript and were replayed — and re-stacked — on later turns. Separate the two roles the dict was conflating: _message_history is now the committed record of prior turns, written only at commit points (bootstrap seeding and _persist_turn). A new _prior_transcript() returns a fresh copy of that record so _build_run_input composes the run input without mutating the store. Successful-run behavior is unchanged (accumulation was always driven by _persist_turn); failed or message-less runs now leave no injected residue. Replace the test that pinned the old build-time mutation with two that pin the new contract (bootstrap seeds from history only; build never writes the store), and add a regression test driving a failed turn followed by a successful one. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 46 +++++++++++----- tests/adapters/agno/test_adapter.py | 81 +++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 19201b7a1..ef7cf438a 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -312,6 +312,32 @@ async def on_cleanup(self, room_id: str) -> None: """Drop the room's accumulated transcript when the agent leaves.""" self._message_history.pop(room_id, None) + def _prior_transcript( + self, + history: AgnoMessages, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> list[Message]: + """Committed prior-turn messages that seed this run, as a fresh list. + + ``_message_history[room_id]`` is the *committed* record of prior turns. + It is written only at commit points — here (seeding rehydrated platform + history on bootstrap) and in :meth:`_persist_turn` (after a successful + run). A *copy* is returned so the caller composes this turn's input + without mutating the committed transcript; otherwise a failed or + message-less run would leave the injected system/user messages behind to + be replayed on the next turn. + + When the Agno agent manages its own history this returns empty — Agno + replays prior turns from its database. + """ + if self._agno_manages_history: + return [] + if is_session_bootstrap: + self._message_history[room_id] = list(history) + return list(self._message_history.setdefault(room_id, [])) + def _build_run_input( self, msg: PlatformMessage, @@ -322,22 +348,16 @@ def _build_run_input( is_session_bootstrap: bool, room_id: str, ) -> list[Message]: - """Build Agno input for this turn. + """Compose this turn's Agno input: prior transcript + injected messages. - When the Agno agent manages its own history, build a fresh current-turn - list and do not seed or replay Band's transcript — Agno supplies prior - turns from its database. Otherwise seed and accumulate a Band-managed - transcript per room. + Built from a *copy* of the committed transcript (see + :meth:`_prior_transcript`), so building the input never mutates + ``_message_history`` and a failed run leaves no injected residue behind. """ message_cls = agno_message_class() - if self._agno_manages_history: - messages: list[Message] = [] - else: - if is_session_bootstrap: - self._message_history[room_id] = list(history) - else: - self._message_history.setdefault(room_id, []) - messages = self._message_history[room_id] + messages = self._prior_transcript( + history, is_session_bootstrap=is_session_bootstrap, room_id=room_id + ) if participants_msg: messages.append( diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 176f75f98..f066d582d 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -30,6 +30,7 @@ from tests.adapters.agno.helpers import ( SchemaTools, openai_tool_schema, + run_input, tool_execution, ) @@ -612,14 +613,17 @@ def test_persist_keeps_only_conversation_roles(self, make_agno_agent): kept = [m.role for m in adapter._message_history["room-1"]] assert kept == ["user", "assistant", "tool"] - def test_bootstrap_seeds_then_followup_accumulates( + def test_bootstrap_seeds_committed_transcript_from_history( self, make_agno_agent, sample_platform_message ): + # Bootstrap seeds the committed transcript from rehydrated history. The + # returned run input is that seed plus this turn's live message, but + # building it must NOT push the live message into the committed store. source, _ = make_agno_agent() adapter = AgnoAdapter(source) seed = [Message(role="user", content="earlier")] - adapter._build_run_input( + run_input_msgs = adapter._build_run_input( sample_platform_message, seed, None, @@ -627,20 +631,79 @@ def test_bootstrap_seeds_then_followup_accumulates( is_session_bootstrap=True, room_id="room-1", ) + + assert [m.content for m in run_input_msgs] == [ + "earlier", + sample_platform_message.format_for_llm(), + ] + # Committed transcript holds only the rehydrated seed. + assert [m.content for m in adapter._message_history["room-1"]] == ["earlier"] + + def test_build_run_input_does_not_mutate_committed_transcript( + self, make_agno_agent, sample_platform_message + ): + # A non-bootstrap turn reads the committed transcript but never writes to + # it; the store is only ever advanced by _persist_turn after a run. + source, _ = make_agno_agent() + adapter = AgnoAdapter(source) + adapter._message_history["room-1"] = [Message(role="user", content="committed")] + adapter._build_run_input( sample_platform_message, [], - None, - None, + "participants", + "contacts", + is_session_bootstrap=False, + room_id="room-1", + ) + + assert [m.content for m in adapter._message_history["room-1"]] == ["committed"] + + +class TestFailedRunDoesNotContaminateNextTurn: + async def test_failed_turn_leaves_no_residue_in_next_run_input( + self, make_agno_agent, tools + ): + # Turn 1 raises mid-run; turn 2 succeeds. The injected system/user + # messages from the failed turn must not survive into turn 2's input. + source, copy = make_agno_agent() + copy.arun = AsyncMock( + side_effect=[RuntimeError("boom"), RunOutput(content="ok")] + ) + adapter = AgnoAdapter(source) + await adapter.on_started("TestBot", "desc") + + first = _msg("room-1", "first question", msg_id="m1") + with pytest.raises(RuntimeError): + await adapter.on_message( + first, + tools, + [], + "P1-participants", + "C1-contacts", + is_session_bootstrap=True, + room_id="room-1", + ) + + second = _msg("room-1", "second question", msg_id="m2") + await adapter.on_message( + second, + tools, + [], + "P2-participants", + "C2-contacts", is_session_bootstrap=False, room_id="room-1", ) - transcript = adapter._message_history["room-1"] - # seed + bootstrap user msg + follow-up user msg - assert len(transcript) == 3 - assert transcript[0].content == "earlier" - assert all(m.role == "user" for m in transcript) + contents = [m.content for m in run_input(copy)] + # No residue from the failed turn 1. + assert not any("P1-participants" in c for c in contents) + assert not any("C1-contacts" in c for c in contents) + assert first.format_for_llm() not in contents + # Turn 2's own injected context and live message are present. + assert any("P2-participants" in c for c in contents) + assert contents[-1] == second.format_for_llm() class TestOnCleanup: From 687dc6217446ac5906ece4e330a53c23cf6e8a26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:46:31 +0000 Subject: [PATCH 55/90] docs(tools): clarify numeric-bound drop is intentionally global The drop applies to every format/adapter, not only the strict providers the comment names; bounds remain enforced at execution via model_validate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/runtime/tools.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index e2a45f74a..b499d898a 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -2089,8 +2089,9 @@ def get_tool_schemas( schema.pop("title", None) # Pydantic Field(ge=..., le=...) renders as JSON-Schema minimum/maximum, # which some providers reject on integer params (e.g. Gemini, and - # Anthropic-backed Agno). The bounds stay enforced at execution via - # model_validate, so drop them from the advertised schema. + # Anthropic-backed Agno). Dropped for every format/adapter on purpose, + # not just the strict providers: the bounds stay enforced at execution + # via model_validate, so advertising them buys nothing. schema = sanitize_tool_schema(schema, drop_numeric_bounds=True) if format == "openai": From 70caa4aecd67bb77e6d07ea5f74d945a8d20a524 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:28:50 +0000 Subject: [PATCH 56/90] perf(agno): cache Band tool build and inject instructions at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ensure_band_tools rebuilt every Band tool schema (model_json_schema + filter + Function construction) on every message, only to discard the already-wired ones after warmup. The built set depends solely on include_contacts (memory inclusion and feature filters are static), so cache the Function list on that flag — at most two builds for the process lifetime. Wiring stays idempotent by name, so cache reuse never double-adds and failed add_tool calls are still retried. Reuse across rooms is safe because entrypoints are room-agnostic (ContextVar routing) and schemas are static given the include flags. Band instruction injection is composed purely from static capabilities, so move it from the lazy first-message path into on_started (once, before any room runs). Drops the _band_instructions_injected flag. Add tests pinning both: schemas built once across same-key turns, and guidance present in additional_context after on_started with no message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 47 ++++++++++++++++++----------- tests/adapters/agno/test_adapter.py | 32 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index ef7cf438a..6b5d187d9 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -180,7 +180,11 @@ def __init__( # set is the union of what any room has needed so far. Tracking wired # names keeps wiring idempotent (no duplicates, never removed). self._wired_tool_names: set[str] = set() - self._band_instructions_injected = False + # Built Functions cached by their only dynamic input (include_contacts), + # so the schema build runs at most twice for the process lifetime rather + # than on every message. Entrypoints are room-agnostic, so the cached + # list is safe to reuse across rooms. + self._band_tools_cache: dict[bool, list[Function]] = {} # Resolved against the runtime agent in on_started, once it exists. self._agno_manages_history = False @@ -250,6 +254,10 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: self._agno_manages_history = self._detect_agno_history(self._agent) self._warn_on_memory_collision(self._agent) + # Band guidance is composed purely from static capabilities, so inject it + # once here -- before any room runs -- rather than lazily on first message. + self._inject_band_instructions() + # Keep the converter's own-agent filtering in sync with our identity, so # rehydrated history maps this agent's past messages to the assistant role. if isinstance(self.history_converter, AgnoHistoryConverter): @@ -509,11 +517,19 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: if self._agent is None: return - new_tools = [ - fn - for fn in self._build_band_tools(tools) - if fn.name not in self._wired_tool_names - ] + # The built Function set depends only on whether contacts are included + # (memory inclusion and feature filters are static), so cache on that + # flag and avoid rebuilding schemas every message. Wiring stays + # idempotent by name, so cache reuse never double-adds a tool. + include_contacts = Capability.CONTACTS in self.features.capabilities or bool( + getattr(tools, "is_hub_room", False) + ) + functions = self._band_tools_cache.get(include_contacts) + if functions is None: + functions = self._build_band_tools(tools, include_contacts=include_contacts) + self._band_tools_cache[include_contacts] = functions + + new_tools = [fn for fn in functions if fn.name not in self._wired_tool_names] wired: list[str] = [] for fn in new_tools: try: @@ -528,9 +544,6 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: len(wired), ", ".join(wired), ) - if not self._band_instructions_injected: - self._inject_band_instructions() - self._band_instructions_injected = True def _inject_band_instructions(self) -> None: """Append Band tool guidance to the copied agent's system message. @@ -556,22 +569,20 @@ def _band_instructions(self) -> str: parts.append(CONTACT_SECTION.strip()) return "\n\n".join(parts) - def _build_band_tools(self, tools: AgentToolsProtocol) -> list[Function]: + def _build_band_tools( + self, tools: AgentToolsProtocol, *, include_contacts: bool + ) -> list[Function]: """Convert Band tool schemas into Agno Functions. Honors the AdapterFeatures include/exclude/category filters via - :func:`filter_tool_schemas`. Contact tools are force-exposed for the - contact-hub room (mirrors LangGraph) regardless of the CONTACTS - capability gate. + :func:`filter_tool_schemas`. ``include_contacts`` is resolved by the + caller (CONTACTS capability or a contact-hub room, mirroring LangGraph) + so the built set can be cached on that flag. """ function_cls = agno_function_class() - effective_include_contacts = ( - Capability.CONTACTS in self.features.capabilities - or bool(getattr(tools, "is_hub_room", False)) - ) schemas = tools.get_openai_tool_schemas( include_memory=Capability.MEMORY in self.features.capabilities, - include_contacts=effective_include_contacts, + include_contacts=include_contacts, ) schemas = filter_tool_schemas( schemas, diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index f066d582d..931f23e8e 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -271,6 +271,29 @@ async def test_no_capabilities_excludes_memory_and_contacts( {"include_memory": False, "include_contacts": False} ] + async def test_schema_build_is_cached_across_turns( + self, make_started_adapter, sample_platform_message + ): + # Same contact flag across turns -> schemas are built once and reused, + # not rebuilt every message. + tools = SchemaTools([openai_tool_schema("band_send_message")]) + adapter, _ = await make_started_adapter() + + for bootstrap in (True, False, False): + await adapter.on_message( + sample_platform_message, + tools, + [], + None, + None, + is_session_bootstrap=bootstrap, + room_id="room-1", + ) + + assert tools.schema_calls == [ + {"include_memory": False, "include_contacts": False} + ] + class TestBandInstructionInjection: """Drive a real Agno agent so we assert on the system prompt Agno actually @@ -319,6 +342,15 @@ async def test_developer_instructions_survive_in_prompt( assert "Keep replies under 10 words." in prompt assert "## Environment" in prompt + async def test_guidance_injected_at_startup_before_any_message( + self, make_started_adapter + ): + # Band guidance is injected in on_started, not lazily on first message. + adapter, copy = await make_started_adapter() + + assert isinstance(copy.additional_context, str) + assert "## Environment" in copy.additional_context + class TestBandEntrypointBinding: async def test_routes_to_execute_tool_call_inside_context(self, tools): From 002a7f3483ef48aa6a798aa7af401330b33de99e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:35:36 +0000 Subject: [PATCH 57/90] refactor(agno): drop redundant converter set_agent_name in on_started SimpleAdapter.on_started already calls set_agent_name on any converter that defines it, and AgnoHistoryConverter does. The Agno override re-did the same call (with a narrower isinstance check) right after super().on_started(), so remove the duplicate. test_syncs_converter_identity still passes via the base. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 6b5d187d9..8bc8e67b4 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -258,10 +258,9 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: # once here -- before any room runs -- rather than lazily on first message. self._inject_band_instructions() - # Keep the converter's own-agent filtering in sync with our identity, so - # rehydrated history maps this agent's past messages to the assistant role. - if isinstance(self.history_converter, AgnoHistoryConverter): - self.history_converter.set_agent_name(agent_name) + # Converter identity (used to map this agent's own past messages to the + # assistant role) is synced by SimpleAdapter.on_started above, which + # calls set_agent_name on any converter that defines it. logger.info("Agno adapter started for agent: %s", agent_name) logger.debug( From 7eecf09dc7953f9e78729910eea648d46c715bd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:37:07 +0000 Subject: [PATCH 58/90] docs(agno): note the fallback-reply divergence from other adapters Unlike the other adapters (deliver only when the agent calls band_send_message), AgnoAdapter falls back to posting the final text itself, addressed to the sender. Document it on the class so the behavior isn't surprising. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 8bc8e67b4..bb02de06b 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -100,6 +100,12 @@ def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: class AgnoAdapter(SimpleAdapter[AgnoMessages]): """Bridge a developer-built Agno agent to Band. + Note on replies: unlike the other adapters (which deliver only when the + agent calls ``band_send_message``), this adapter falls back to posting the + agent's final text itself, addressed to the message sender, when + ``band_send_message`` was not called. Steer the agent to call the tool when + you need explicit recipients or no auto-reply. + Note on ``Emit.THOUGHTS``: when enabled, the agent's **raw** ``reasoning_content`` is posted to the room as a thought event. This can surface chain-of-thought and intermediate context, so it is strictly From e82dcdc4f1a9d46619dc3277c04dcc5acb3587e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:47:33 +0000 Subject: [PATCH 59/90] docs(agno): correct _inject_band_instructions docstring The runtime agent may be factory-built (not deep-copied), guidance is appended to additional_context (not a "system message"), and injection now runs once at startup. Reword the docstring to match. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index bb02de06b..1e4a12c52 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -551,10 +551,10 @@ def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: ) def _inject_band_instructions(self) -> None: - """Append Band tool guidance to the copied agent's system message. + """Append Band tool guidance to the runtime agent's ``additional_context``. - Appended to Agno's ``additional_context`` so the developer's own - instructions are preserved. + Appending (rather than replacing) preserves the developer's own + instructions. Called once at startup, before any room runs. """ if self._agent is None: return From c4e3bf837e43e1650f7c708a2fa3fb9228413792 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:53:13 +0000 Subject: [PATCH 60/90] refactor(agno): inline single-use _with_agent decorator _with_agent wrapped exactly one method (_run_agent) with a generic Concatenate/ParamSpec/TypeVar/wraps decorator just to guard against a None agent and inject it as the first arg. Inline a two-line guard in _run_agent instead, dropping the decorator, the P/R type vars, and the wraps/Concatenate/ ParamSpec/TypeVar imports. Same RuntimeError, no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 1e4a12c52..2452cde38 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,8 +8,7 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar -from functools import wraps -from typing import TYPE_CHECKING, Any, ClassVar, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter @@ -37,9 +36,6 @@ logger = logging.getLogger(__name__) -P = ParamSpec("P") -R = TypeVar("R") - # These tools already produce visible room output. _SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) @@ -62,19 +58,6 @@ def _tool_name(execution: Any) -> str: return getattr(execution, "tool_name", None) or "" -def _with_agent( - fn: Callable[Concatenate[Any, AgnoAgent, P], Awaitable[R]], -) -> Callable[Concatenate[Any, P], Awaitable[R]]: - @wraps(fn) - async def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: - agent = getattr(self, "_agent", None) - if agent is None: - raise RuntimeError("AgnoAdapter was used before on_started()") - return await fn(self, agent, *args, **kwargs) - - return wrapper - - def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: async def _entrypoint(**kwargs: Any) -> str: active = _current_tools.get() @@ -383,10 +366,8 @@ def _build_run_input( messages.append(message_cls(role="user", content=msg.format_for_llm())) return messages - @_with_agent async def _run_agent( self, - agent: AgnoAgent, messages: list[Message], tools: AgentToolsProtocol, *, @@ -394,6 +375,10 @@ async def _run_agent( msg_id: str, ) -> RunOutput | None: """Run the Agno agent with the room's tools bound for this call.""" + agent = self._agent + if agent is None: + raise RuntimeError("AgnoAdapter was used before on_started()") + session_id = self._session_id_factory(room_id) logger.debug( "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", From da49a7f3c5e187ea6b628178907f5097d34eaf11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:56:46 +0000 Subject: [PATCH 61/90] test(agno): drop duplicate no-capabilities schema-flags case test_no_capabilities_excludes_memory_and_contacts asserted the same setup and outcome ({include_memory: False, include_contacts: False}) as TestHubContactExposure.test_normal_room_does_not_request_contacts, which is the better-placed baseline (it anchors the normal-vs-hub contrast and still covers include_memory=False). No coverage lost. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- tests/adapters/agno/test_adapter.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 931f23e8e..6324ef49e 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -251,26 +251,6 @@ async def test_capability_flags_drive_schema_request( {"include_memory": True, "include_contacts": True} ] - async def test_no_capabilities_excludes_memory_and_contacts( - self, make_started_adapter, sample_platform_message - ): - tools = SchemaTools([]) - adapter, _ = await make_started_adapter() - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.schema_calls == [ - {"include_memory": False, "include_contacts": False} - ] - async def test_schema_build_is_cached_across_turns( self, make_started_adapter, sample_platform_message ): From 0a36b811dcee0c703af2c3045da9764f6a687977 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:57:16 +0000 Subject: [PATCH 62/90] Revert "refactor(agno): inline single-use _with_agent decorator" Keep the _with_agent decorator: the agent-injection pattern is preferred for readability over an inline None-guard in _run_agent. This reverts commit c4e3bf8. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- src/band/adapters/agno.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 2452cde38..1e4a12c52 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,7 +8,8 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar -from typing import TYPE_CHECKING, Any, ClassVar +from functools import wraps +from typing import TYPE_CHECKING, Any, ClassVar, Concatenate, ParamSpec, TypeVar from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter @@ -36,6 +37,9 @@ logger = logging.getLogger(__name__) +P = ParamSpec("P") +R = TypeVar("R") + # These tools already produce visible room output. _SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) @@ -58,6 +62,19 @@ def _tool_name(execution: Any) -> str: return getattr(execution, "tool_name", None) or "" +def _with_agent( + fn: Callable[Concatenate[Any, AgnoAgent, P], Awaitable[R]], +) -> Callable[Concatenate[Any, P], Awaitable[R]]: + @wraps(fn) + async def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: + agent = getattr(self, "_agent", None) + if agent is None: + raise RuntimeError("AgnoAdapter was used before on_started()") + return await fn(self, agent, *args, **kwargs) + + return wrapper + + def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: async def _entrypoint(**kwargs: Any) -> str: active = _current_tools.get() @@ -366,8 +383,10 @@ def _build_run_input( messages.append(message_cls(role="user", content=msg.format_for_llm())) return messages + @_with_agent async def _run_agent( self, + agent: AgnoAgent, messages: list[Message], tools: AgentToolsProtocol, *, @@ -375,10 +394,6 @@ async def _run_agent( msg_id: str, ) -> RunOutput | None: """Run the Agno agent with the room's tools bound for this call.""" - agent = self._agent - if agent is None: - raise RuntimeError("AgnoAdapter was used before on_started()") - session_id = self._session_id_factory(room_id) logger.debug( "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", From 3bcd2ec35dbc5ec650d44082316bd567c97ce19d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:07:56 +0000 Subject: [PATCH 63/90] docs: add Agno to README adapter tables and framework list The Agno integration was missing from the canonical docs: - README "Supported Adapters" table (+ agno extra) - README adapter emit-support table (EXECUTION + THOUGHTS, no TASK_EVENTS) - README "direct example files" list - AGENTS.md / CLAUDE.md multi-framework feature line CLAUDE.md is a symlink to AGENTS.md, so the single edit covers both. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- AGENTS.md | 2 +- README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6baa6f471..6712353f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This is a Python SDK that connects AI agents to the Band collaborative platform. ## Core Features -1. Multi-framework support (LangGraph, Anthropic, CrewAI, Claude SDK, Codex, Pydantic AI, Parlant, Gemini, Letta, Google ADK, OpenCode) +1. Multi-framework support (LangGraph, Anthropic, CrewAI, Claude SDK, Codex, Pydantic AI, Parlant, Gemini, Letta, Google ADK, OpenCode, Agno) 2. A2A protocol support: Bridge to remote A2A agents and expose Band peers as A2A endpoints 3. ACP integration: Editor-facing server and subprocess client adapters (Cursor, Codex, Claude Code) 4. Platform tools for chat, contacts, and memory management diff --git a/README.md b/README.md index 7c27de87d..fb9758ab9 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ For the full picture, rooms, contacts, platform tools, and how messages flow - s | Google ADK | `google_adk` | `GoogleADKAdapter` | | [examples](examples/google_adk/) | | Parlant | `parlant` | `ParlantAdapter` | | [examples](examples/parlant/) | | Letta | `letta` | `LettaAdapter` | | [examples](examples/letta/) | +| Agno | `agno` | `AgnoAdapter` | | [examples](examples/agno/) | | Codex | `codex` | `CodexAdapter` | [docs](docs/adapters/codex.md) | [examples](examples/codex/) | | OpenCode | `opencode` | `OpencodeAdapter` | | [examples](examples/opencode/) | @@ -354,6 +355,7 @@ Adapter emit support: | ------- | ----------- | ---------- | ------------- | | Codex | Yes | Yes | Yes | | Claude SDK | Yes | Yes | - | +| Agno | Yes | Yes | - | | OpenCode | Yes | - | Yes | | Letta | Yes | - | Yes | | Anthropic | Yes | - | - | @@ -675,7 +677,7 @@ uv run python examples/run_agent.py --example anthropic uv run python examples/run_agent.py --example codex ``` -`examples/run_agent.py` supports `langgraph`, `pydantic_ai`, `anthropic`, `claude_sdk`, `parlant`, `crewai`, `codex`, `a2a`, and `a2a_gateway`, plus contact-management variants. Other supported adapters have direct example files: `examples/gemini/01_basic_agent.py`, `examples/google_adk/01_basic_agent.py`, `examples/letta/01_basic_agent.py`, and `examples/opencode/01_basic_agent.py`. +`examples/run_agent.py` supports `langgraph`, `pydantic_ai`, `anthropic`, `claude_sdk`, `parlant`, `crewai`, `codex`, `a2a`, and `a2a_gateway`, plus contact-management variants. Other supported adapters have direct example files: `examples/gemini/01_basic_agent.py`, `examples/google_adk/01_basic_agent.py`, `examples/letta/01_basic_agent.py`, `examples/agno/01_basic_agent.py`, and `examples/opencode/01_basic_agent.py`. For a multi-framework collaboration demo that puts CrewAI agents and A2A-bridged services in the same room, see [examples/mixed](examples/mixed/). From 52b4d45d876910d0f76fd18b9ee6dba3197022d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:23:55 +0000 Subject: [PATCH 64/90] chore(deps): drop unused tenacity direct dependency tenacity was added to the dev and dev-crewai extras for "rate-limited platform reconnects in E2E tests", but that retry is hand-rolled in tests/e2e/helpers/agent.py (429/503 + server retry_after + exponential backoff) and tenacity is never imported anywhere. Remove the direct dep; it stays in uv.lock transitively for the packages that actually use it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BDtg9mTXZf2s54j1mNuyLq --- pyproject.toml | 4 ---- uv.lock | 4 ---- 2 files changed, 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f778606df..2117b2270 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -188,8 +188,6 @@ dev = [ "boto3>=1.35.0", # Pretty E2E test output "rich>=13.0.0", - # Retry/cooldown for rate-limited platform reconnects in E2E tests - "tenacity>=8.0.0", # Development tools "pre-commit>=3.0.0", "ruff>=0.8.0", @@ -217,8 +215,6 @@ dev-crewai = [ "pillow>=12.1.1", # Pretty E2E test output "rich>=13.0.0", - # Retry/cooldown for rate-limited platform reconnects in E2E tests - "tenacity>=8.0.0", # Development tools "ruff>=0.8.0", "pyrefly>=0.18.0", diff --git a/uv.lock b/uv.lock index 07e639c87..c14604525 100644 --- a/uv.lock +++ b/uv.lock @@ -607,7 +607,6 @@ dev = [ { name = "ruff" }, { name = "slack-sdk" }, { name = "starlette" }, - { name = "tenacity" }, { name = "thenvoi-testing-python" }, { name = "uvicorn" }, { name = "werkzeug" }, @@ -628,7 +627,6 @@ dev-crewai = [ { name = "pytest-timeout" }, { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" } }, { name = "ruff" }, - { name = "tenacity" }, { name = "thenvoi-testing-python" }, ] gemini = [ @@ -779,8 +777,6 @@ requires-dist = [ { name = "starlette", marker = "extra == 'acp'", specifier = ">=0.40.0" }, { name = "starlette", marker = "extra == 'dev'", specifier = ">=0.40.0" }, { name = "starlette", marker = "extra == 'slack'", specifier = ">=0.40.0" }, - { name = "tenacity", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "tenacity", marker = "extra == 'dev-crewai'", specifier = ">=8.0.0" }, { name = "thenvoi-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "thenvoi-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, { name = "uvicorn", marker = "extra == 'a2a-gateway'", specifier = ">=0.32.0" }, From d0a40036ee04df05f95861c780218549ab732490 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:31:29 +0000 Subject: [PATCH 65/90] fix(agno): resolve Band tools per-run to stop contact-tool leak across rooms The Agno adapter wired Band tools additively onto a single shared agent, accumulating the union of every room's tools. Once a contact-hub room wired the contact-management tools, those schemas stayed visible to the LLM in every room. Replace the additive wiring with a callable-tools factory installed on the agent in on_started. Agno resolves it per run into the run context (concurrency-safe, no shared-state mutation), so each room offers exactly its own tool set, gated by the CONTACTS capability or a contact-hub room. The factory reads the active room from the existing _current_tools ContextVar (which also routes execution) and re-includes the developer's own tools, resolving a developer-supplied callable tools factory via Agno's own helpers. Agno's callable cache is disabled so the factory runs every turn regardless of session_id; built Functions stay cached per contact-flag in the adapter. Tests updated to assert per-run resolution and strict per-room visibility, including a leak regression and an end-to-end check that the model receives only the active room's tools. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011GRcuVE64WTHHo9h9F4cry --- src/band/adapters/agno.py | 132 +++++++++----- tests/adapters/agno/conftest.py | 4 + tests/adapters/agno/helpers.py | 36 ++++ tests/adapters/agno/test_adapter.py | 265 ++++++++++++++-------------- tests/framework_configs/adapters.py | 4 +- 5 files changed, 264 insertions(+), 177 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 1e4a12c52..2ed04671e 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -182,14 +182,18 @@ def __init__( # Running per-room transcripts; bootstrap history seeds each room. self._message_history: dict[str, list[Message]] = {} - # Band tools are wired additively onto the single shared agent: the tool - # set is the union of what any room has needed so far. Tracking wired - # names keeps wiring idempotent (no duplicates, never removed). - self._wired_tool_names: set[str] = set() + # Band tools are exposed per-run via a callable-tools factory installed on + # the shared agent (see _resolve_room_tools), so each room's run offers + # exactly its own tool set -- no cross-room schema leakage. The user's own + # tools (those they configured on the agent, captured at startup) are + # re-included on every run. "User" here is the user who built the Agno + # agent, not a chat end-user. + self._user_tools: list[Any] = [] + self._user_tools_factory: Callable[..., Any] | None = None # Built Functions cached by their only dynamic input (include_contacts), # so the schema build runs at most twice for the process lifetime rather - # than on every message. Entrypoints are room-agnostic, so the cached - # list is safe to reuse across rooms. + # than on every run. Entrypoints route through the _current_tools + # ContextVar, so the cached list is safe to reuse across rooms. self._band_tools_cache: dict[bool, list[Function]] = {} # Resolved against the runtime agent in on_started, once it exists. @@ -260,6 +264,18 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: self._agno_manages_history = self._detect_agno_history(self._agent) self._warn_on_memory_collision(self._agent) + # Install per-run tool resolution: capture the user's own tools, then + # replace ``agent.tools`` with our factory so each run offers exactly the + # active room's tool set (see _resolve_room_tools). Disable Agno's + # callable-tools cache so the factory runs every turn regardless of + # session_id; we cache the built Functions ourselves in _band_tools_cache. + self._capture_user_tools(self._agent) + self._agent.cache_callables = False + # Agno's `tools` type annotation lists only sync factories, but its + # resolver (ainvoke_callable_factory) explicitly supports async ones, and + # the adapter only ever runs via async `arun`. + self._agent.tools = self._resolve_room_tools # type: ignore[assignment] + # Band guidance is composed purely from static capabilities, so inject it # once here -- before any room runs -- rather than lazily on first message. self._inject_band_instructions() @@ -296,7 +312,6 @@ async def on_message( is_session_bootstrap, ) - self._ensure_band_tools(tools) messages = self._build_run_input( msg, history, @@ -508,47 +523,78 @@ async def _send_reply( msg.sender_name, ) - def _ensure_band_tools(self, tools: AgentToolsProtocol) -> None: - """Additively wire this room's Band tools onto the shared agent. + def _capture_user_tools(self, agent: AgnoAgent) -> None: + """Capture the user's own tools before installing the room factory. - The agent accumulates the union of tools any room has needed. Wiring is - idempotent by name: a tool already wired (e.g. from an earlier room) is - not re-added. This means once a contact-hub room is seen, contact tool - schemas remain visible in all rooms on the shared agent -- intentional, - not strict per-room visibility. Execution stays room-correct regardless - because each tool entrypoint routes through the current room's - AgentTools via the ``_current_tools`` ContextVar. + "User" here is the user who built the Agno agent (not a chat end-user). + Replacing ``agent.tools`` with our per-run factory (see + :meth:`_resolve_room_tools`) would otherwise drop whatever tools the user + configured, so we stash them and re-include them on every run. A + user-supplied *callable* tools factory is kept as-is and resolved per run + with Agno's own semantics; a static list is copied. """ - if self._agent is None: - return + from agno.tools import Toolkit + from agno.tools.function import Function + from agno.utils.callables import is_callable_factory + + tools = getattr(agent, "tools", None) + if tools is None: + self._user_tools = [] + self._user_tools_factory = None + elif is_callable_factory(tools, excluded_types=(Toolkit, Function)): + self._user_tools = [] + self._user_tools_factory = tools + else: + self._user_tools = list(tools) + self._user_tools_factory = None + + async def _resolve_room_tools(self, run_context: Any = None) -> list[Any]: + """Per-run tool factory: developer tools + the active room's Band tools. + + Installed as ``agent.tools`` in :meth:`on_started`. Agno invokes it once + per run (its own cache disabled) via ``ainvoke_callable_factory`` and + resolves the result into that run's context rather than mutating shared + agent state -- so concurrent rooms never see each other's tools. The + active room is read from the ``_current_tools`` ContextVar bound around + ``arun`` in :meth:`_run_agent` (the same binding that routes tool + execution), keeping visibility and execution aligned. Band tools are + gated per room: the CONTACTS capability or a contact-hub room includes + the contact tools, so a normal room never sees them even after a hub room + has run. + """ + user_tools = await self._resolve_user_tools(run_context) + + active = _current_tools.get() + if active is None: + # Outside a bound run we cannot know the room; expose only the user's + # own tools rather than guessing Band tool visibility. + return user_tools - # The built Function set depends only on whether contacts are included - # (memory inclusion and feature filters are static), so cache on that - # flag and avoid rebuilding schemas every message. Wiring stays - # idempotent by name, so cache reuse never double-adds a tool. include_contacts = Capability.CONTACTS in self.features.capabilities or bool( - getattr(tools, "is_hub_room", False) + getattr(active, "is_hub_room", False) ) - functions = self._band_tools_cache.get(include_contacts) - if functions is None: - functions = self._build_band_tools(tools, include_contacts=include_contacts) - self._band_tools_cache[include_contacts] = functions - - new_tools = [fn for fn in functions if fn.name not in self._wired_tool_names] - wired: list[str] = [] - for fn in new_tools: - try: - self._agent.add_tool(fn) - self._wired_tool_names.add(fn.name) - wired.append(fn.name) - except RuntimeError as e: - logger.warning("Could not wire Band tool %s: %s", fn.name, e) - if wired: - logger.info( - "Wired %d Band tool(s) into Agno agent: %s", - len(wired), - ", ".join(wired), - ) + band = self._band_tools_cache.get(include_contacts) + if band is None: + band = self._build_band_tools(active, include_contacts=include_contacts) + self._band_tools_cache[include_contacts] = band + return [*user_tools, *band] + + async def _resolve_user_tools(self, run_context: Any) -> list[Any]: + """Resolve the user's own tools for this run. + + A static list is returned as a fresh copy; a user-supplied callable + factory is invoked with Agno's own signature injection + (agent/run_context/session_state) and may be sync or async. + """ + if self._user_tools_factory is None: + return list(self._user_tools) + + from agno.utils.callables import ainvoke_callable_factory + + resolved = await ainvoke_callable_factory( + self._user_tools_factory, self._agent, run_context + ) + return list(resolved) if resolved else [] def _inject_band_instructions(self) -> None: """Append Band tool guidance to the runtime agent's ``additional_context``. diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index 3b60412df..0e37fae65 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -53,6 +53,10 @@ def _make( copy.add_tool = MagicMock() # Real Agno agents default additional_context to None; mirror that. copy.additional_context = None + # The adapter captures the user's tools at startup, then installs a + # callable factory. A bare MagicMock `.tools` is itself callable and would + # be mistaken for a user-supplied tools factory, so pin it to a list. + copy.tools = [] copy.arun = AsyncMock( return_value=response if response is not None else RunOutput() ) diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py index b85e4aa63..45fe9b2bc 100644 --- a/tests/adapters/agno/helpers.py +++ b/tests/adapters/agno/helpers.py @@ -55,6 +55,9 @@ def __init__(self, content: str = "ok") -> None: super().__init__(id="capturing", provider="fake") self._content = content self.captured_messages: list[Message] | None = None + # Tool names Agno offered the model on the most recent response call, + # so tests can assert per-run tool exposure end-to-end. + self.captured_tool_names: list[str] | None = None def invoke(self, *args: Any, **kwargs: Any) -> Any: ... async def ainvoke(self, *args: Any, **kwargs: Any) -> Any: ... @@ -65,6 +68,9 @@ def _parse_provider_response_delta(self, *args: Any, **kwargs: Any) -> Any: ... async def aresponse(self, messages: list[Message], **kwargs: Any) -> ModelResponse: self.captured_messages = messages + self.captured_tool_names = [ + _tool_schema_name(t) for t in (kwargs.get("tools") or []) + ] return ModelResponse(content=self._content) @property @@ -76,6 +82,16 @@ def captured_system_prompt(self) -> str: ) +def _tool_schema_name(tool: Any) -> str | None: + """Best-effort tool name from an Agno Function or an OpenAI-format dict.""" + name = getattr(tool, "name", None) + if name: + return name + if isinstance(tool, dict): + return tool.get("function", {}).get("name") or tool.get("name") + return None + + class SchemaTools(FakeAgentTools): """FakeAgentTools that returns real OpenAI-format schemas and records the capability flags it was asked for (FakeAgentTools returns [] by default).""" @@ -94,6 +110,26 @@ def get_openai_tool_schemas( return self._schemas +class ContactAwareTools(SchemaTools): + """Like real AgentTools: contact tool schemas appear only when contacts are + requested. Always exposes ``band_send_message``; adds ``band_add_contact`` + when ``include_contacts`` is True (CONTACTS capability or a hub room).""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__([], **kwargs) + + def get_openai_tool_schemas( + self, *, include_memory: bool = False, include_contacts: bool = True + ) -> list[dict[str, Any]]: + self.schema_calls.append( + {"include_memory": include_memory, "include_contacts": include_contacts} + ) + schemas = [openai_tool_schema("band_send_message")] + if include_contacts: + schemas.append(openai_tool_schema("band_add_contact")) + return schemas + + def openai_tool_schema(name: str) -> dict[str, Any]: return { "type": "function", diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 6324ef49e..949f71c5b 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -2,9 +2,10 @@ Conformance already covers init defaults, ``on_started`` name/description, and generic converter wiring; these tests pin Agno-only behavior: agent deep-copy, -memory-collision warning, Band-tool wiring, the ContextVar tool binding, -fallback-send, emit reporting, transcript persistence, and cleanup. Rehydration -of platform history lives in ``test_rehydration.py``. +memory-collision warning, per-run Band-tool resolution (the callable-tools +factory + ContextVar binding), strict per-room tool visibility, fallback-send, +emit reporting, transcript persistence, and cleanup. Rehydration of platform +history lives in ``test_rehydration.py``. """ from __future__ import annotations @@ -16,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agno.agent import Agent as AgnoAgent from agno.models.message import Message from agno.run.agent import RunOutput @@ -28,6 +30,8 @@ from band.testing import FakeAgentTools from tests.adapters.agno.helpers import ( + CapturingModel, + ContactAwareTools, SchemaTools, openai_tool_schema, run_input, @@ -69,7 +73,9 @@ def _factory_agent_stub() -> MagicMock: agent.add_history_to_context = False agent.db = None agent.additional_context = None - agent.add_tool = MagicMock() + # A bare MagicMock `.tools` is callable and would be mistaken for a + # user-supplied tools factory; pin it to a list. + agent.tools = [] agent.arun = AsyncMock(return_value=RunOutput()) agent.deep_copy = MagicMock() return agent @@ -191,45 +197,40 @@ async def test_no_warning_without_memory_capability(self, make_agno_agent): await adapter.on_started("TestBot", "desc") -class TestBandToolWiring: - async def test_wires_each_schema_once( - self, make_started_adapter, sample_platform_message - ): +class TestRoomToolResolution: + """Band tools are exposed per-run via the ``_resolve_room_tools`` factory + Agno calls each turn, not wired onto the agent. These pin what that factory + returns and the schema requests it makes for the active room.""" + + async def test_resolves_band_tools_for_active_room(self, make_started_adapter): tools = SchemaTools( [ openai_tool_schema("band_send_message"), openai_tool_schema("band_lookup_peers"), ] ) - adapter, copy = await make_started_adapter() + adapter, _ = await make_started_adapter() - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - # Second turn must not re-wire (idempotent by name via _wired_tool_names). - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with _bind_room_tools(tools): + resolved = await adapter._resolve_room_tools() - assert copy.add_tool.call_count == 2 - wired_names = [call.args[0].name for call in copy.add_tool.call_args_list] - assert wired_names == ["band_send_message", "band_lookup_peers"] + assert [fn.name for fn in resolved] == [ + "band_send_message", + "band_lookup_peers", + ] - async def test_capability_flags_drive_schema_request( - self, make_started_adapter, sample_platform_message - ): + async def test_no_band_tools_outside_a_bound_room(self, make_started_adapter): + # Defensive: with no active room bound, the factory exposes no Band tools + # (and does not even request schemas) rather than guessing visibility. + tools = SchemaTools([openai_tool_schema("band_send_message")]) + adapter, _ = await make_started_adapter() + + resolved = await adapter._resolve_room_tools() # no _bind_room_tools + + assert resolved == [] + assert tools.schema_calls == [] + + async def test_capability_flags_drive_schema_request(self, make_started_adapter): tools = SchemaTools([]) adapter, _ = await make_started_adapter( features=AdapterFeatures( @@ -237,43 +238,44 @@ async def test_capability_flags_drive_schema_request( ) ) - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) + with _bind_room_tools(tools): + await adapter._resolve_room_tools() assert tools.schema_calls == [ {"include_memory": True, "include_contacts": True} ] - async def test_schema_build_is_cached_across_turns( - self, make_started_adapter, sample_platform_message - ): - # Same contact flag across turns -> schemas are built once and reused, - # not rebuilt every message. + async def test_schema_build_is_cached_across_runs(self, make_started_adapter): + # Same contact flag across runs -> schemas are built once and reused, + # not rebuilt every turn. tools = SchemaTools([openai_tool_schema("band_send_message")]) adapter, _ = await make_started_adapter() - for bootstrap in (True, False, False): - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=bootstrap, - room_id="room-1", - ) + with _bind_room_tools(tools): + await adapter._resolve_room_tools() + await adapter._resolve_room_tools() + await adapter._resolve_room_tools() assert tools.schema_calls == [ {"include_memory": False, "include_contacts": False} ] + async def test_user_tools_are_reincluded(self, make_agno_agent): + # Replacing agent.tools with our factory must not drop the user's own + # tools; they are re-included alongside the room's Band tools. + user_tool = object() + source, copy = make_agno_agent() + copy.tools = [user_tool] + adapter = AgnoAdapter(source) + await adapter.on_started("TestBot", "desc") + + tools = SchemaTools([openai_tool_schema("band_send_message")]) + with _bind_room_tools(tools): + resolved = await adapter._resolve_room_tools() + + assert resolved[0] is user_tool + assert [getattr(t, "name", None) for t in resolved[1:]] == ["band_send_message"] + class TestBandInstructionInjection: """Drive a real Agno agent so we assert on the system prompt Agno actually @@ -814,21 +816,15 @@ async def test_two_rooms_get_isolated_sessions_and_inputs( class TestHubContactExposure: """The adapter decides contact exposure (mirrors LangGraph): the CONTACTS - capability OR a hub room force-includes contact tool schemas.""" + capability OR a hub room force-includes contact tool schemas, resolved per + run so visibility is strictly per-room.""" async def test_normal_room_does_not_request_contacts(self, make_started_adapter): adapter, _ = await make_started_adapter() tools = SchemaTools([], room_id="room-A") - await adapter.on_message( - _msg("room-A", "hi"), - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) + with _bind_room_tools(tools): + await adapter._resolve_room_tools() assert tools.schema_calls == [ {"include_memory": False, "include_contacts": False} @@ -838,62 +834,32 @@ async def test_hub_room_forces_contacts(self, make_started_adapter): adapter, _ = await make_started_adapter() tools = SchemaTools([], hub_room_id="hub", room_id="hub") - await adapter.on_message( - _msg("hub", "hi"), - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="hub", - ) + with _bind_room_tools(tools): + await adapter._resolve_room_tools() assert tools.schema_calls == [ {"include_memory": False, "include_contacts": True} ] - async def test_contact_tools_added_additively_after_hub(self, make_started_adapter): - adapter, copy = await make_started_adapter() + async def test_contacts_do_not_leak_into_normal_room_after_hub( + self, make_started_adapter + ): + # Core regression: after a hub room exposes contact tools, a subsequent + # normal room's resolution must NOT include them. The old additive wiring + # accumulated the union on the shared agent; per-run resolution does not. + adapter, _ = await make_started_adapter() - normal = SchemaTools( - [openai_tool_schema("band_send_message")], room_id="room-A" - ) - await adapter.on_message( - _msg("room-A", "hi"), - normal, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - assert [c.args[0].name for c in copy.add_tool.call_args_list] == [ - "band_send_message" - ] + hub = ContactAwareTools(hub_room_id="hub", room_id="hub") + with _bind_room_tools(hub): + hub_names = [fn.name for fn in await adapter._resolve_room_tools()] + assert "band_add_contact" in hub_names - hub = SchemaTools( - [ - openai_tool_schema("band_send_message"), - openai_tool_schema("band_add_contact"), - ], - hub_room_id="hub", - room_id="hub", - ) - await adapter.on_message( - _msg("hub", "hi"), - hub, - [], - None, - None, - is_session_bootstrap=True, - room_id="hub", - ) + normal = ContactAwareTools(room_id="room-A") + with _bind_room_tools(normal): + normal_names = [fn.name for fn in await adapter._resolve_room_tools()] - # band_send_message is not re-added; band_add_contact is additively wired. - wired = [c.args[0].name for c in copy.add_tool.call_args_list] - assert wired == ["band_send_message", "band_add_contact"] - # A run still executes per message against the single shared agent. - assert copy.arun.await_count == 2 + assert normal_names == ["band_send_message"] + assert "band_add_contact" not in normal_names class TestFeatureFilters: @@ -907,40 +873,34 @@ class TestFeatureFilters: openai_tool_schema("band_add_contact"), # contacts ] - async def _wired_names(self, adapter, copy) -> list[str]: - await adapter.on_message( - _msg("room-A", "hi"), - SchemaTools(self.ALL_SCHEMAS), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - return [c.args[0].name for c in copy.add_tool.call_args_list] + async def _resolved_names(self, adapter) -> list[str]: + tools = SchemaTools(self.ALL_SCHEMAS) + with _bind_room_tools(tools): + resolved = await adapter._resolve_room_tools() + return [fn.name for fn in resolved] async def test_include_tools_keeps_only_named(self, make_started_adapter): - adapter, copy = await make_started_adapter( + adapter, _ = await make_started_adapter( features=AdapterFeatures(include_tools=["band_send_message"]) ) - assert await self._wired_names(adapter, copy) == ["band_send_message"] + assert await self._resolved_names(adapter) == ["band_send_message"] async def test_exclude_tools_drops_named(self, make_started_adapter): - adapter, copy = await make_started_adapter( + adapter, _ = await make_started_adapter( features=AdapterFeatures(exclude_tools=["band_send_message"]) ) - names = await self._wired_names(adapter, copy) + names = await self._resolved_names(adapter) assert "band_send_message" not in names assert "band_lookup_peers" in names async def test_include_categories_keeps_only_category(self, make_started_adapter): - adapter, copy = await make_started_adapter( + adapter, _ = await make_started_adapter( features=AdapterFeatures(include_categories=["chat"]) ) - assert sorted(await self._wired_names(adapter, copy)) == [ + assert sorted(await self._resolved_names(adapter)) == [ "band_lookup_peers", "band_send_message", ] @@ -994,3 +954,42 @@ async def send_event(self, *args: Any, **kwargs: Any) -> dict[str, Any]: is_session_bootstrap=True, room_id="room-A", ) + + +class TestPerRunToolExposureEndToEnd: + """Drive a real Agno agent so we assert on the tools Agno actually offered + the model per run -- proving the factory is installed and invoked per turn, + and that contact tools do not leak across rooms through the shared agent.""" + + async def test_model_receives_only_active_room_tools(self): + model = CapturingModel() + agno = AgnoAgent(model=model, instructions="You are Dev.") + adapter = AgnoAdapter(agno) + await adapter.on_started("Bot", "desc") + + # Hub room: contact tools are offered to the model. + hub = ContactAwareTools(hub_room_id="hub", room_id="hub") + await adapter.on_message( + _msg("hub", "hi"), + hub, + [], + None, + None, + is_session_bootstrap=True, + room_id="hub", + ) + assert model.captured_tool_names is not None + assert "band_add_contact" in model.captured_tool_names + + # Normal room afterwards on the same shared agent: no contact leak. + normal = ContactAwareTools(room_id="room-A") + await adapter.on_message( + _msg("room-A", "hi"), + normal, + [], + None, + None, + is_session_bootstrap=True, + room_id="room-A", + ) + assert model.captured_tool_names == ["band_send_message"] diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index aee8ef671..29c607c04 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -683,7 +683,9 @@ def _build_agno_config() -> AdapterConfig: # owns those); assert the adapter-level state instead. expected_initial_values={ "agent": None, # the run copy is built in on_started - "_wired_tool_names": set(), # tools are wired additively per room + # Band tools are resolved per-run via a callable factory installed in + # on_started, cached by contact-flag; nothing is cached before start. + "_band_tools_cache": {}, }, # No model/prompt kwargs to customize; nothing to assert here. custom_kwargs={}, From 4be4cf6dac7c0a5fe7eb81c0f8992401bd6ac54c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 08:09:55 +0300 Subject: [PATCH 66/90] refactor(agno): import agno symbols directly instead of lazy accessors The agno adapter and converter modules are only ever lazy-imported (via the band.adapters/converters __init__ accessors), so their module bodies run only when agno is installed. The _require_agno + lru_cache accessor indirection (agno_message_class, agno_function_class, agno_toolkit_class, agno_callable_helpers) bought nothing over a plain import and obscured the call sites. Import the needed agno symbols at module top behind a single try/except ImportError, matching the gemini adapter, and use Message/Function/Toolkit/ is_callable_factory/ainvoke_callable_factory directly. Annotation-only symbols (AgnoAgent, RunOutput) stay under TYPE_CHECKING. Also finishes the developer->user tool docstring rename. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 40 ++++++++++++++----------------- src/band/converters/agno.py | 47 ++++++++++--------------------------- 2 files changed, 29 insertions(+), 58 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 2ed04671e..e0e97ee8a 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -20,20 +20,24 @@ Emit, PlatformMessage, ) -from band.converters.agno import ( - AgnoHistoryConverter, - AgnoMessages, - agno_function_class, - agno_message_class, -) +from band.converters.agno import AgnoHistoryConverter, AgnoMessages from band.runtime.prompts import BASE_INSTRUCTIONS, CONTACT_SECTION, MEMORY_SECTION from band.runtime.tools import get_band_tool_category +try: + from agno.models.message import Message + from agno.tools import Toolkit + from agno.tools.function import Function + from agno.utils.callables import ainvoke_callable_factory, is_callable_factory +except ImportError as e: + raise ImportError( + "agno is required for the Agno adapter.\n" + "Install with: pip install 'band-sdk[agno]'" + ) from e + if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent - from agno.models.message import Message from agno.run.agent import RunOutput - from agno.tools.function import Function logger = logging.getLogger(__name__) @@ -382,20 +386,17 @@ def _build_run_input( :meth:`_prior_transcript`), so building the input never mutates ``_message_history`` and a failed run leaves no injected residue behind. """ - message_cls = agno_message_class() messages = self._prior_transcript( history, is_session_bootstrap=is_session_bootstrap, room_id=room_id ) if participants_msg: messages.append( - message_cls(role="user", content=f"[System]: {participants_msg}") + Message(role="user", content=f"[System]: {participants_msg}") ) if contacts_msg: - messages.append( - message_cls(role="user", content=f"[System]: {contacts_msg}") - ) - messages.append(message_cls(role="user", content=msg.format_for_llm())) + messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) + messages.append(Message(role="user", content=msg.format_for_llm())) return messages @_with_agent @@ -533,10 +534,6 @@ def _capture_user_tools(self, agent: AgnoAgent) -> None: user-supplied *callable* tools factory is kept as-is and resolved per run with Agno's own semantics; a static list is copied. """ - from agno.tools import Toolkit - from agno.tools.function import Function - from agno.utils.callables import is_callable_factory - tools = getattr(agent, "tools", None) if tools is None: self._user_tools = [] @@ -549,7 +546,7 @@ def _capture_user_tools(self, agent: AgnoAgent) -> None: self._user_tools_factory = None async def _resolve_room_tools(self, run_context: Any = None) -> list[Any]: - """Per-run tool factory: developer tools + the active room's Band tools. + """Per-run tool factory: user tools + the active room's Band tools. Installed as ``agent.tools`` in :meth:`on_started`. Agno invokes it once per run (its own cache disabled) via ``ainvoke_callable_factory`` and @@ -589,8 +586,6 @@ async def _resolve_user_tools(self, run_context: Any) -> list[Any]: if self._user_tools_factory is None: return list(self._user_tools) - from agno.utils.callables import ainvoke_callable_factory - resolved = await ainvoke_callable_factory( self._user_tools_factory, self._agent, run_context ) @@ -630,7 +625,6 @@ def _build_band_tools( caller (CONTACTS capability or a contact-hub room, mirroring LangGraph) so the built set can be cached on that flag. """ - function_cls = agno_function_class() schemas = tools.get_openai_tool_schemas( include_memory=Capability.MEMORY in self.features.capabilities, include_contacts=include_contacts, @@ -649,7 +643,7 @@ def _build_band_tools( fn = schema.get("function", {}) if name := fn.get("name"): band_tools.append( - function_cls( + Function( name=name, description=fn.get("description", "") or "", parameters=fn.get("parameters") diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py index 3304414b6..bd4c63be8 100644 --- a/src/band/converters/agno.py +++ b/src/band/converters/agno.py @@ -4,43 +4,23 @@ import json import logging -from functools import lru_cache -from importlib import import_module -from typing import TYPE_CHECKING, Any +from typing import Any from band.core.protocols import HistoryConverter from ._tool_parsing import parse_tool_call, parse_tool_result -if TYPE_CHECKING: +try: from agno.models.message import Message - from agno.tools.function import Function +except ImportError as e: + raise ImportError( + "agno is required for the Agno converter.\n" + "Install with: pip install 'band-sdk[agno]'" + ) from e logger = logging.getLogger(__name__) -# Forward reference keeps agno optional at import time. -AgnoMessages = list["Message"] - - -def _require_agno(module: str, attr: str) -> Any: - try: - return getattr(import_module(module), attr) - except ImportError as e: - raise ImportError( - "Agno dependencies not installed. Install with: uv add band-sdk[agno]" - ) from e - - -@lru_cache(maxsize=1) -def agno_message_class() -> type[Message]: - """Agno Message class.""" - return _require_agno("agno.models.message", "Message") - - -@lru_cache(maxsize=1) -def agno_function_class() -> type[Function]: - """Agno Function class.""" - return _require_agno("agno.tools.function", "Function") +AgnoMessages = list[Message] def _flush_tool_calls( @@ -48,9 +28,8 @@ def _flush_tool_calls( ) -> None: if not pending_calls: return - message_cls = agno_message_class() messages.append( - message_cls( + Message( role="assistant", content=None, tool_calls=list(pending_calls), @@ -115,9 +94,8 @@ def _append_tool_result(messages: AgnoMessages, content: str) -> None: parsed = parse_tool_result(content) if parsed is None: return - message_cls = agno_message_class() messages.append( - message_cls( + Message( role="tool", tool_call_id=parsed.tool_call_id, tool_name=parsed.name, @@ -130,7 +108,6 @@ def _append_tool_result(messages: AgnoMessages, content: str) -> None: def _text_message(self, hist: dict[str, Any]) -> Message: # Converter output is rehydrated history; tag it so Agno's # any(msg.from_history) check doesn't re-add stored session history. - message_cls = agno_message_class() content = hist.get("content", "") # Own-agent detection keys on sender_name, not a stable sender_id: # formatted history dicts carry only sender_name (see @@ -140,8 +117,8 @@ def _text_message(self, hist: dict[str, Any]) -> Message: if hist.get("role") == "assistant" and hist.get("sender_name") == ( self._agent_name ): - return message_cls(role="assistant", content=content, from_history=True) + return Message(role="assistant", content=content, from_history=True) sender_name = hist.get("sender_name", "") formatted = f"[{sender_name}]: {content}" if sender_name else content - return message_cls(role="user", content=formatted, from_history=True) + return Message(role="user", content=formatted, from_history=True) From a463527eba95da655c009e93346274241eff1dbf Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 08:19:59 +0300 Subject: [PATCH 67/90] refactor(agno): merge user-tools pair and tidy on_started typing Collapse the mutually-exclusive _user_tools / _user_tools_factory fields into a single _user_tools (list | callable factory), removing the keep-in-sync invariant; _resolve_user_tools now discriminates with callable(). A stored value is either a plain list (never callable) or the factory (always callable), so the check is unambiguous. Bind the factory result to a local 'agent' in on_started so the agent-dependent setup calls receive a non-optional AgnoAgent (clears the AgnoAgent | None type warnings), and annotate the dynamically-typed agent.tools local as Any. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index e0e97ee8a..87c8a607e 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -190,10 +190,10 @@ def __init__( # the shared agent (see _resolve_room_tools), so each room's run offers # exactly its own tool set -- no cross-room schema leakage. The user's own # tools (those they configured on the agent, captured at startup) are - # re-included on every run. "User" here is the user who built the Agno - # agent, not a chat end-user. - self._user_tools: list[Any] = [] - self._user_tools_factory: Callable[..., Any] | None = None + # re-included on every run, and may be either a static list or a per-run + # callable factory. "User" here is the user who built the Agno agent, not + # a chat end-user. + self._user_tools: list[Any] | Callable[..., Any] = [] # Built Functions cached by their only dynamic input (include_contacts), # so the schema build runs at most twice for the process lifetime rather # than on every run. Entrypoints route through the _current_tools @@ -264,21 +264,22 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: """ await super().on_started(agent_name, agent_description) - self._agent = self._agent_factory() - self._agno_manages_history = self._detect_agno_history(self._agent) - self._warn_on_memory_collision(self._agent) + agent = self._agent_factory() + self._agent = agent + self._agno_manages_history = self._detect_agno_history(agent) + self._warn_on_memory_collision(agent) # Install per-run tool resolution: capture the user's own tools, then # replace ``agent.tools`` with our factory so each run offers exactly the # active room's tool set (see _resolve_room_tools). Disable Agno's # callable-tools cache so the factory runs every turn regardless of # session_id; we cache the built Functions ourselves in _band_tools_cache. - self._capture_user_tools(self._agent) - self._agent.cache_callables = False + self._capture_user_tools(agent) + agent.cache_callables = False # Agno's `tools` type annotation lists only sync factories, but its # resolver (ainvoke_callable_factory) explicitly supports async ones, and # the adapter only ever runs via async `arun`. - self._agent.tools = self._resolve_room_tools # type: ignore[assignment] + agent.tools = self._resolve_room_tools # type: ignore[assignment] # Band guidance is composed purely from static capabilities, so inject it # once here -- before any room runs -- rather than lazily on first message. @@ -534,16 +535,13 @@ def _capture_user_tools(self, agent: AgnoAgent) -> None: user-supplied *callable* tools factory is kept as-is and resolved per run with Agno's own semantics; a static list is copied. """ - tools = getattr(agent, "tools", None) + tools: Any = getattr(agent, "tools", None) if tools is None: self._user_tools = [] - self._user_tools_factory = None elif is_callable_factory(tools, excluded_types=(Toolkit, Function)): - self._user_tools = [] - self._user_tools_factory = tools + self._user_tools = tools # a per-run callable factory else: - self._user_tools = list(tools) - self._user_tools_factory = None + self._user_tools = list(tools) # a static list async def _resolve_room_tools(self, run_context: Any = None) -> list[Any]: """Per-run tool factory: user tools + the active room's Band tools. @@ -583,11 +581,11 @@ async def _resolve_user_tools(self, run_context: Any) -> list[Any]: factory is invoked with Agno's own signature injection (agent/run_context/session_state) and may be sync or async. """ - if self._user_tools_factory is None: + if not callable(self._user_tools): return list(self._user_tools) resolved = await ainvoke_callable_factory( - self._user_tools_factory, self._agent, run_context + self._user_tools, self._agent, run_context ) return list(resolved) if resolved else [] From a1c2f98164e6b4b1ed323e1cedefc1c7c3753c7c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 08:28:35 +0300 Subject: [PATCH 68/90] docs(agno): unify on "user" terminology and dedupe clarifying note The adapter referred to the SDK integrator who builds the agent as both "developer" and "user". Standardize on "user" (matching the _user_tools attribute), anchor its meaning once in the class docstring (the integrator who built/configured the agent, not a chat end-user), and drop the duplicated clarifying note. Comment-only change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 87c8a607e..5d345a5c6 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -102,7 +102,10 @@ def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: class AgnoAdapter(SimpleAdapter[AgnoMessages]): - """Bridge a developer-built Agno agent to Band. + """Bridge a user-built Agno agent to Band. + + "User" throughout this adapter means the SDK integrator who built and + configured the Agno agent — never a chat end-user (``sender_type`` "User"). Note on replies: unlike the other adapters (which deliver only when the agent calls ``band_send_message``), this adapter falls back to posting the @@ -132,7 +135,7 @@ def __init__( features: AdapterFeatures | None = None, session_id_factory: Callable[[str], str] = lambda room_id: room_id, ) -> None: - """Bridge a developer-built Agno agent to Band. + """Bridge a user-built Agno agent to Band. Provide **exactly one** of ``agent`` or ``agent_factory``: @@ -191,8 +194,7 @@ def __init__( # exactly its own tool set -- no cross-room schema leakage. The user's own # tools (those they configured on the agent, captured at startup) are # re-included on every run, and may be either a static list or a per-run - # callable factory. "User" here is the user who built the Agno agent, not - # a chat end-user. + # callable factory. self._user_tools: list[Any] | Callable[..., Any] = [] # Built Functions cached by their only dynamic input (include_contacts), # so the schema build runs at most twice for the process lifetime rather @@ -258,7 +260,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: """Build the runtime agent and sync the converter identity. The runtime agent is produced by the factory captured at construction — - either the caller's ``agent.deep_copy`` or a developer ``agent_factory``. + either the caller's ``agent.deep_copy`` or a user-supplied ``agent_factory``. Agent-dependent checks run here (not in ``__init__``) so the factory is only ever invoked at startup. """ @@ -307,7 +309,7 @@ async def on_message( is_session_bootstrap: bool, room_id: str, ) -> None: - """Run the developer's Agno agent and ensure a reply is sent.""" + """Run the user's Agno agent and ensure a reply is sent.""" logger.info( "Room %s msg %s: handling from %s (sender=%s, bootstrap=%s)", room_id, @@ -528,7 +530,6 @@ async def _send_reply( def _capture_user_tools(self, agent: AgnoAgent) -> None: """Capture the user's own tools before installing the room factory. - "User" here is the user who built the Agno agent (not a chat end-user). Replacing ``agent.tools`` with our per-run factory (see :meth:`_resolve_room_tools`) would otherwise drop whatever tools the user configured, so we stash them and re-include them on every run. A @@ -592,7 +593,7 @@ async def _resolve_user_tools(self, run_context: Any) -> list[Any]: def _inject_band_instructions(self) -> None: """Append Band tool guidance to the runtime agent's ``additional_context``. - Appending (rather than replacing) preserves the developer's own + Appending (rather than replacing) preserves the user's own instructions. Called once at startup, before any room runs. """ if self._agent is None: From a3cca70bdd0cd0189dcb7fac28782aeb045badb5 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 08:29:11 +0300 Subject: [PATCH 69/90] refactor(agno): extract agent-factory resolution to a helper Move the 'exactly one of agent/agent_factory' validation and mode selection out of __init__ into a _resolve_agent_factory staticmethod, so __init__ reads as resolve-factory -> init-base -> set-up-state. Pure transform, no behavior change (same ValueErrors, same deep_copy semantics, still validated before super().__init__). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 5d345a5c6..78516d0b4 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -162,19 +162,7 @@ def __init__( are keyed by ``room_id``). To keep a single shared session across rooms, pass e.g. ``session_id_factory=lambda _r: "fixed"``. """ - if agent is not None and agent_factory is not None: - raise ValueError( - "AgnoAdapter accepts `agent` or `agent_factory`, not both." - ) - if agent is not None: - # Run against a copy so the caller's configured agent stays immutable. - factory: Callable[[], AgnoAgent] = agent.deep_copy - elif agent_factory is not None: - factory = agent_factory - else: - raise ValueError( - "AgnoAdapter requires exactly one of `agent` or `agent_factory`." - ) + factory = self._resolve_agent_factory(agent, agent_factory) super().__init__( history_converter=history_converter or AgnoHistoryConverter(), @@ -205,6 +193,29 @@ def __init__( # Resolved against the runtime agent in on_started, once it exists. self._agno_manages_history = False + @staticmethod + def _resolve_agent_factory( + agent: AgnoAgent | None, + agent_factory: Callable[[], AgnoAgent] | None, + ) -> Callable[[], AgnoAgent]: + """Pick the single agent factory from the mutually-exclusive args. + + Exactly one of ``agent`` or ``agent_factory`` must be given. When an + ``agent`` is provided, the adapter runs against ``agent.deep_copy`` so the + caller's configured instance stays immutable. + """ + if agent is not None and agent_factory is not None: + raise ValueError( + "AgnoAdapter accepts `agent` or `agent_factory`, not both." + ) + if agent is not None: + return agent.deep_copy + if agent_factory is not None: + return agent_factory + raise ValueError( + "AgnoAdapter requires exactly one of `agent` or `agent_factory`." + ) + @property def agent(self) -> AgnoAgent | None: """The running Agno agent, initialized in on_started.""" From 72e757b3e9913e062082aaf3c4128eba1600f928 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 09:47:46 +0300 Subject: [PATCH 70/90] test(agno): add restart reconnect delay and document E2E model requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent restart tests reopen a WebSocket for an agent_id whose previous connection just closed; reconnecting within the platform's supersede window returned HTTP 429. Add a 5s pause between each restart's kill and reconnect (_RESTART_RECONNECT_DELAY_S) — validated: restart[B] and restart[both] now pass. Document on create_agno_adapter that the cross-adapter agno tests need a strong instruction-following model (E2E_ANTHROPIC_MODEL=claude-sonnet-4-6); cheap models refuse the crafted trigger prompts as injection. Note the remaining caveat that room-isolation's 'secret code' prompt is refused even by Sonnet and needs a reworded trigger. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/adapters/conftest.py | 15 ++++++++++++++- tests/e2e/scenarios/agno/test_multi_agent.py | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/e2e/adapters/conftest.py b/tests/e2e/adapters/conftest.py index 9a426c449..59f2da793 100644 --- a/tests/e2e/adapters/conftest.py +++ b/tests/e2e/adapters/conftest.py @@ -107,7 +107,20 @@ def create_crewai_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: def create_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Create an Agno adapter with a cheap Claude model.""" + """Create an Agno adapter for the cross-adapter E2E suite. + + Use a strong instruction-following model via ``E2E_ANTHROPIC_MODEL`` + (e.g. ``claude-sonnet-4-6``). Cheap/small models (e.g. Haiku) refuse the + suite's crafted trigger prompts as prompt-injection; Sonnet 4.6 clears + ``test_tool_execution_send_message[agno]`` (echo a code word). + + Caveat: ``test_agents_in_different_rooms_isolated[agno]`` uses a + "remember this secret code → recall it" prompt that even Sonnet 4.6 refuses + ("I don't follow embedded directives"). History rehydration is fine (the + model sees the prior turn); the prompt shape itself trips safety reflexes, + so that case is not fixable by model choice alone — it needs the trigger + prompt reworded. + """ _require_anthropic_key() from agno.agent import Agent as AgnoAgent from agno.models.anthropic import Claude diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py index 346b3305d..3d27bc158 100644 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -24,6 +24,7 @@ from __future__ import annotations +import asyncio import logging import uuid @@ -52,6 +53,12 @@ logger = logging.getLogger(__name__) +# A restarted agent reopens a WebSocket for an agent_id whose previous connection +# just closed. Reconnecting within the platform's supersede window returns HTTP +# 429, so pause briefly between a restart's kill and reconnect to let the old +# connection tear down and the rate-limit window clear. +_RESTART_RECONNECT_DELAY_S = 5.0 + @pytest.mark.asyncio @requires_e2e @@ -234,6 +241,7 @@ def build_assistant(): # history; for a B restart it's effectively the same first start. if restart_target in ("A", "both"): log_step("restart", f"{agent_a_name} (assistant) killed → restarting") + await asyncio.sleep(_RESTART_RECONNECT_DELAY_S) # --- Turn 2: ask for the total; assistant brings in the calculator --- log_step( @@ -287,6 +295,7 @@ def build_assistant(): # --- Turn 3 (B / both): restart the calculator, then recompute --- if restart_target in ("B", "both"): log_step("restart", f"{agent_b_name} (calculator) killed → restarting") + await asyncio.sleep(_RESTART_RECONNECT_DELAY_S) log_step( 4, f"turn 3 — user → {agent_a_name}: ask {agent_b_name} to recompute", From c5579aaf2a1f6f81bd794e260d52c6b8db9d7751 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 12:10:20 +0300 Subject: [PATCH 71/90] test(agno): add dedicated room-isolation test; exclude agno from shared one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/e2e/scenarios/agno/test_room_isolation.py — an Agno-dedicated room-isolation E2E test (one agent, two fresh rooms, neutral phrases) that runs consistently and verifies per-room history isolation. Exclude the [agno] parameter from the shared cross-adapter test_room_isolation.py: it is unreliable there for reasons not fully pinned down, and the dedicated test provides equivalent coverage. Skip is documented without over-claiming a root cause. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../e2e/scenarios/agno/test_room_isolation.py | 206 ++++++++++++++++++ tests/e2e/scenarios/test_room_isolation.py | 12 + 2 files changed, 218 insertions(+) create mode 100644 tests/e2e/scenarios/agno/test_room_isolation.py diff --git a/tests/e2e/scenarios/agno/test_room_isolation.py b/tests/e2e/scenarios/agno/test_room_isolation.py new file mode 100644 index 000000000..745b2f184 --- /dev/null +++ b/tests/e2e/scenarios/agno/test_room_isolation.py @@ -0,0 +1,206 @@ +"""Live: one Agno agent keeps per-room context isolated across two rooms. + +Agno-dedicated analogue of the cross-adapter +``tests/e2e/scenarios/test_room_isolation.py``. That shared test fails for Agno +because its "secret code, remember it" prompt is refused by cautious models +(even Sonnet) as an injected directive — not an SDK defect. This version +controls for that exactly as ``test_context_persistence.py`` does: + +1. **Fresh rooms** (no standing/stale content from prior runs). +2. **Benign, disjoint random phrases** (no "code"/"secret" trigger, and the two + phrases share no words so the negative assertion is unambiguous). +3. An agent **instructed** to treat Band's ``@[[id]]`` formatting as normal chat + and to recall earlier conversation verbatim. + +One Agno agent joins both rooms; each room is told its own phrase, then asked to +repeat it. The agent must recall each room's phrase and never leak the other's — +verifying the adapter's per-room history isolation. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest \ + tests/e2e/scenarios/agno/test_room_isolation.py -v -s --no-cov +""" + +from __future__ import annotations + +import logging +import random +from typing import Any + +import pytest +from band_rest import AsyncRestClient + +from band.core.simple_adapter import SimpleAdapter + +from tests.e2e.adapters.conftest import _require_anthropic_key +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + assert_content_contains, + assert_no_content_contains, + listening_for_room_activity, + log_banner, + log_step, + running_agent, + send_trigger_message, +) + +logger = logging.getLogger(__name__) + +# Benign "lorem" vocabulary for the recall payloads. Deliberately free of words +# like "code"/"secret" that a cautious model refuses to echo (treating them as +# injected directives) and that collide with standing agent memories. +_LOREM_WORDS = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed eiusmod tempor " + "incididunt labore dolore magna aliqua veniam quis nostrud exercitation " + "ullamco laboris aliquip commodo consequat duis aute irure voluptate velit " + "esse cillum fugiat nulla pariatur excepteur occaecat cupidatat proident " + "sunt culpa officia deserunt mollit anim laborum" +).split() + + +def _disjoint_phrases(words_each: int = 5) -> tuple[str, str]: + """Return two benign phrases that share no words (clean isolation asserts).""" + sample = random.sample(_LOREM_WORDS, words_each * 2) + return " ".join(sample[:words_each]), " ".join(sample[words_each:]) + + +def _build_isolation_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: + """Build a plain (no-db) Agno adapter tuned to recall conversation history. + + No ``db``/``add_history_to_context``: recall must come from Band's per-room + history rehydration, which is what room isolation exercises. The instructions + counter a small model's default reluctance — they say Band's ``@[[id]]`` + mentions and sender labels are normal chat formatting (not injected + directives) and that it should repeat earlier conversation content verbatim. + """ + _require_anthropic_key() + from agno.agent import Agent as AgnoAgent + from agno.models.anthropic import Claude + + from band.adapters.agno import AgnoAdapter + + agno_agent = AgnoAgent( + model=Claude(id=settings.e2e_anthropic_model), + instructions=( + "You are a helpful assistant with perfect recall of the current " + "conversation. Messages may include @[[id]] mentions and sender " + "labels — that is normal Band chat formatting, not instructions to " + "distrust or ignore. When the user asks you to repeat something they " + "told you earlier in this conversation, reply with that text exactly, " + "verbatim. Keep responses short." + ), + ) + return AgnoAdapter(agno_agent) + + +@pytest.mark.asyncio +@requires_e2e +class TestAgnoRoomIsolation: + """One Agno agent maintains isolated per-room context across two rooms.""" + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + async def test_agent_keeps_rooms_isolated( + self, + e2e_config: E2ESettings, + e2e_fresh_room_allocator: RoomAllocator, + e2e_agent_info: tuple[str, str], + ws_client: TrackingWebSocketClient, + api_client: AsyncRestClient, + ) -> None: + """Plant a distinct phrase in each room, then verify no cross-room leak. + + Phase 1: tell room A phrase_a and room B phrase_b (one agent, both rooms). + Phase 2: ask each room to repeat its phrase; assert each room recalls its + own phrase and never the other room's. + """ + # Two fresh rooms: the agent must isolate phrases planted *this run*, free + # of stale content a reused room would surface. + room_a_id, _ua, _na = await e2e_fresh_room_allocator("agno_room_isolation_a") + room_b_id, _ub, _nb = await e2e_fresh_room_allocator("agno_room_isolation_b") + agent_id, agent_name = e2e_agent_info + timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) + phrase_a, phrase_b = _disjoint_phrases() + + log_banner("Scenario: Agno keeps two rooms' context isolated") + logger.info("Room A=%s phrase_a=%r", room_a_id, phrase_a) + logger.info("Room B=%s phrase_b=%r", room_b_id, phrase_b) + + async with running_agent( + _build_isolation_agno_adapter(e2e_config), + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + config=e2e_config, + ): + # --- Phase 1: plant each room's phrase (sequential: one agent) --- + for label, room_id, phrase in ( + ("A", room_a_id, phrase_a), + ("B", room_b_id, phrase_b), + ): + log_step(1, f"planting phrase in room {label} ({room_id})") + async with listening_for_room_activity( + ws_client, + room_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_ack: + await send_trigger_message( + api_client, + room_id, + f'Please remember this exact phrase for me: "{phrase}". ' + "Just confirm you've got it.", + agent_name, + agent_id, + ) + await wait_ack() + + # --- Phase 2: query each room and verify isolation --- + log_step(2, "asking room A to repeat its phrase") + async with listening_for_room_activity( + ws_client, + room_a_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_a: + await send_trigger_message( + api_client, + room_a_id, + "Earlier in this conversation I asked you to remember an exact " + "phrase. Repeat that phrase back to me, word for word.", + agent_name, + agent_id, + ) + room_a_received = await wait_a() + + log_step(2, "asking room B to repeat its phrase") + async with listening_for_room_activity( + ws_client, + room_b_id, + message_types=("text",), + sender_id=agent_id, + timeout=timeout, + raise_on_timeout=True, + ) as wait_b: + await send_trigger_message( + api_client, + room_b_id, + "Earlier in this conversation I asked you to remember an exact " + "phrase. Repeat that phrase back to me, word for word.", + agent_name, + agent_id, + ) + room_b_received = await wait_b() + + # Room A recalls only phrase_a; room B recalls only phrase_b. + assert_content_contains(room_a_received, phrase_a) + assert_no_content_contains(room_a_received, phrase_b) + assert_content_contains(room_b_received, phrase_b) + assert_no_content_contains(room_b_received, phrase_a) + log_step("assert", "each room recalled only its own phrase") + + log_banner("Scenario PASSED") diff --git a/tests/e2e/scenarios/test_room_isolation.py b/tests/e2e/scenarios/test_room_isolation.py index bbd48568a..e74f1f9a4 100644 --- a/tests/e2e/scenarios/test_room_isolation.py +++ b/tests/e2e/scenarios/test_room_isolation.py @@ -65,6 +65,18 @@ async def test_agents_in_different_rooms_isolated( the room or create a fresh agent. """ adapter_name, factory = adapter_entry + + # Agno is verified by the dedicated + # tests/e2e/scenarios/agno/test_room_isolation.py and is excluded here: + # it is unreliable in this shared cross-adapter test for reasons we have + # not fully pinned down (the dedicated test covers the same per-room + # isolation with a setup that runs consistently). + if adapter_name == "agno": + pytest.skip( + "Agno is covered by tests/e2e/scenarios/agno/test_room_isolation.py " + "(excluded here: unreliable in this shared test)" + ) + timeout = e2e_config.e2e_timeout agent_id, agent_name = e2e_agent_info From ebc6a5d2525d3373389a700e7558db0c0463199c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 12:51:49 +0300 Subject: [PATCH 72/90] fix(e2e): record fresh room before participant-add to avoid leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In e2e_fresh_room_allocator.allocate, the room is created on the platform (with the agent in it) before the user participant is added. Append the room id to the cleanup lists immediately after creation so it is still torn down if the participant-add fails — otherwise it leaks against the agent's room cap. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/fixtures/rooms.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/fixtures/rooms.py b/tests/e2e/fixtures/rooms.py index ab2fee0a3..485314c30 100644 --- a/tests/e2e/fixtures/rooms.py +++ b/tests/e2e/fixtures/rooms.py @@ -151,12 +151,15 @@ async def allocate(name: str) -> tuple[str, str, str]: if response.data is None: pytest.fail("create_agent_chat returned no data") room_id = response.data.id + # Record for teardown immediately: the room (with the agent in it) now + # exists on the platform, so it must be cleaned up even if adding the + # user participant below fails — otherwise it leaks against the cap. + e2e_created_room_ids.append(room_id) + created.append(room_id) await client.agent_api_participants.add_agent_chat_participant( room_id, participant=ParticipantRequest(participant_id=user_peer.id, role="member"), ) - e2e_created_room_ids.append(room_id) - created.append(room_id) logger.info("E2E: Created fresh room %s for '%s'", room_id, name) return room_id, user_peer.id, user_peer.name From f4a3e8e552d780f143ed1b7d54784d43b8aa8d60 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 12:52:08 +0300 Subject: [PATCH 73/90] test(e2e): use fresh rooms for room-isolation; drop agno special-casing Switch the shared room-isolation test to fresh rooms per run (via e2e_fresh_room_allocator) instead of a reused/shared Room B whose accumulated history bloated context into timeouts and surfaced stale codes on recall. Remove the agno exclusion and the dedicated Agno room-isolation test: the 'remember a secret code, repeat it back' wording is refused nondeterministically by every adapter/model (e.g. langgraph/gpt-4o-mini and anthropic both refused a fresh-room run while pydantic_ai passed), so Agno is not special and needs no separate handling. The shared test's wording flakiness is pre-existing and affects all adapters equally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../e2e/scenarios/agno/test_room_isolation.py | 206 ------------------ tests/e2e/scenarios/test_room_isolation.py | 38 +--- 2 files changed, 12 insertions(+), 232 deletions(-) delete mode 100644 tests/e2e/scenarios/agno/test_room_isolation.py diff --git a/tests/e2e/scenarios/agno/test_room_isolation.py b/tests/e2e/scenarios/agno/test_room_isolation.py deleted file mode 100644 index 745b2f184..000000000 --- a/tests/e2e/scenarios/agno/test_room_isolation.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Live: one Agno agent keeps per-room context isolated across two rooms. - -Agno-dedicated analogue of the cross-adapter -``tests/e2e/scenarios/test_room_isolation.py``. That shared test fails for Agno -because its "secret code, remember it" prompt is refused by cautious models -(even Sonnet) as an injected directive — not an SDK defect. This version -controls for that exactly as ``test_context_persistence.py`` does: - -1. **Fresh rooms** (no standing/stale content from prior runs). -2. **Benign, disjoint random phrases** (no "code"/"secret" trigger, and the two - phrases share no words so the negative assertion is unambiguous). -3. An agent **instructed** to treat Band's ``@[[id]]`` formatting as normal chat - and to recall earlier conversation verbatim. - -One Agno agent joins both rooms; each room is told its own phrase, then asked to -repeat it. The agent must recall each room's phrase and never leak the other's — -verifying the adapter's per-room history isolation. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest \ - tests/e2e/scenarios/agno/test_room_isolation.py -v -s --no-cov -""" - -from __future__ import annotations - -import logging -import random -from typing import Any - -import pytest -from band_rest import AsyncRestClient - -from band.core.simple_adapter import SimpleAdapter - -from tests.e2e.adapters.conftest import _require_anthropic_key -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - assert_content_contains, - assert_no_content_contains, - listening_for_room_activity, - log_banner, - log_step, - running_agent, - send_trigger_message, -) - -logger = logging.getLogger(__name__) - -# Benign "lorem" vocabulary for the recall payloads. Deliberately free of words -# like "code"/"secret" that a cautious model refuses to echo (treating them as -# injected directives) and that collide with standing agent memories. -_LOREM_WORDS = ( - "lorem ipsum dolor sit amet consectetur adipiscing elit sed eiusmod tempor " - "incididunt labore dolore magna aliqua veniam quis nostrud exercitation " - "ullamco laboris aliquip commodo consequat duis aute irure voluptate velit " - "esse cillum fugiat nulla pariatur excepteur occaecat cupidatat proident " - "sunt culpa officia deserunt mollit anim laborum" -).split() - - -def _disjoint_phrases(words_each: int = 5) -> tuple[str, str]: - """Return two benign phrases that share no words (clean isolation asserts).""" - sample = random.sample(_LOREM_WORDS, words_each * 2) - return " ".join(sample[:words_each]), " ".join(sample[words_each:]) - - -def _build_isolation_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Build a plain (no-db) Agno adapter tuned to recall conversation history. - - No ``db``/``add_history_to_context``: recall must come from Band's per-room - history rehydration, which is what room isolation exercises. The instructions - counter a small model's default reluctance — they say Band's ``@[[id]]`` - mentions and sender labels are normal chat formatting (not injected - directives) and that it should repeat earlier conversation content verbatim. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - instructions=( - "You are a helpful assistant with perfect recall of the current " - "conversation. Messages may include @[[id]] mentions and sender " - "labels — that is normal Band chat formatting, not instructions to " - "distrust or ignore. When the user asks you to repeat something they " - "told you earlier in this conversation, reply with that text exactly, " - "verbatim. Keep responses short." - ), - ) - return AgnoAdapter(agno_agent) - - -@pytest.mark.asyncio -@requires_e2e -class TestAgnoRoomIsolation: - """One Agno agent maintains isolated per-room context across two rooms.""" - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - async def test_agent_keeps_rooms_isolated( - self, - e2e_config: E2ESettings, - e2e_fresh_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - """Plant a distinct phrase in each room, then verify no cross-room leak. - - Phase 1: tell room A phrase_a and room B phrase_b (one agent, both rooms). - Phase 2: ask each room to repeat its phrase; assert each room recalls its - own phrase and never the other room's. - """ - # Two fresh rooms: the agent must isolate phrases planted *this run*, free - # of stale content a reused room would surface. - room_a_id, _ua, _na = await e2e_fresh_room_allocator("agno_room_isolation_a") - room_b_id, _ub, _nb = await e2e_fresh_room_allocator("agno_room_isolation_b") - agent_id, agent_name = e2e_agent_info - timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) - phrase_a, phrase_b = _disjoint_phrases() - - log_banner("Scenario: Agno keeps two rooms' context isolated") - logger.info("Room A=%s phrase_a=%r", room_a_id, phrase_a) - logger.info("Room B=%s phrase_b=%r", room_b_id, phrase_b) - - async with running_agent( - _build_isolation_agno_adapter(e2e_config), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - # --- Phase 1: plant each room's phrase (sequential: one agent) --- - for label, room_id, phrase in ( - ("A", room_a_id, phrase_a), - ("B", room_b_id, phrase_b), - ): - log_step(1, f"planting phrase in room {label} ({room_id})") - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_ack: - await send_trigger_message( - api_client, - room_id, - f'Please remember this exact phrase for me: "{phrase}". ' - "Just confirm you've got it.", - agent_name, - agent_id, - ) - await wait_ack() - - # --- Phase 2: query each room and verify isolation --- - log_step(2, "asking room A to repeat its phrase") - async with listening_for_room_activity( - ws_client, - room_a_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_a: - await send_trigger_message( - api_client, - room_a_id, - "Earlier in this conversation I asked you to remember an exact " - "phrase. Repeat that phrase back to me, word for word.", - agent_name, - agent_id, - ) - room_a_received = await wait_a() - - log_step(2, "asking room B to repeat its phrase") - async with listening_for_room_activity( - ws_client, - room_b_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_b: - await send_trigger_message( - api_client, - room_b_id, - "Earlier in this conversation I asked you to remember an exact " - "phrase. Repeat that phrase back to me, word for word.", - agent_name, - agent_id, - ) - room_b_received = await wait_b() - - # Room A recalls only phrase_a; room B recalls only phrase_b. - assert_content_contains(room_a_received, phrase_a) - assert_no_content_contains(room_a_received, phrase_b) - assert_content_contains(room_b_received, phrase_b) - assert_no_content_contains(room_b_received, phrase_a) - log_step("assert", "each room recalled only its own phrase") - - log_banner("Scenario PASSED") diff --git a/tests/e2e/scenarios/test_room_isolation.py b/tests/e2e/scenarios/test_room_isolation.py index e74f1f9a4..f2bce27e5 100644 --- a/tests/e2e/scenarios/test_room_isolation.py +++ b/tests/e2e/scenarios/test_room_isolation.py @@ -23,7 +23,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, @@ -47,41 +47,27 @@ async def test_agents_in_different_rooms_isolated( ws_client: TrackingWebSocketClient, adapter_entry: tuple[str, AdapterFactory], api_client: AsyncRestClient, - e2e_adapter_room: tuple[str, str, str], - e2e_isolation_room_b: tuple[str, str, str], + e2e_fresh_room_allocator: RoomAllocator, e2e_agent_info: tuple[str, str], ): """Agents in different rooms don't see each other's context. - Room A (adapter's dedicated room): Send "The code is " - Room B (shared isolation room): Send "The code is " + Room A: Send "The code is " + Room B: Send "The code is " Room A: Ask "What's the code?" -> Assert unique_a, not unique_b Room B: Ask "What's the code?" -> Assert unique_b, not unique_a - Uses unique keywords per adapter+run to avoid cross-adapter and - cross-run contamination in shared rooms that persist across sessions. - Note: Room B is shared across all adapters; stale history accumulates - across runs. If LLMs start confusing old codes with new ones, prune - the room or create a fresh agent. + Uses fresh rooms per run (via e2e_fresh_room_allocator) so no stale + history accumulates — otherwise a reused room bloats the rehydrated + context into timeouts or surfaces old codes on recall. Unique per-run + keywords additionally guard against any cross-run confusion. """ adapter_name, factory = adapter_entry - - # Agno is verified by the dedicated - # tests/e2e/scenarios/agno/test_room_isolation.py and is excluded here: - # it is unreliable in this shared cross-adapter test for reasons we have - # not fully pinned down (the dedicated test covers the same per-room - # isolation with a setup that runs consistently). - if adapter_name == "agno": - pytest.skip( - "Agno is covered by tests/e2e/scenarios/agno/test_room_isolation.py " - "(excluded here: unreliable in this shared test)" - ) - timeout = e2e_config.e2e_timeout agent_id, agent_name = e2e_agent_info - # Unique keywords per adapter AND per run to prevent stale history - # from confusing the LLM in rooms that persist across test sessions. + # Distinct keywords per room so the cross-room assertions can't pass by + # coincidence; per-adapter/run suffix keeps logs unambiguous. run_id = uuid.uuid4().hex[:6] code_a = f"ALPHA_{adapter_name.upper()}_{run_id}" code_b = f"BRAVO_{adapter_name.upper()}_{run_id}" @@ -93,8 +79,8 @@ async def test_agents_in_different_rooms_isolated( code_b, ) - room_a_id, _user_id, _user_name = e2e_adapter_room - room_b_id = e2e_isolation_room_b[0] + room_a_id, _ua, _na = await e2e_fresh_room_allocator("room-isolation-a") + room_b_id, _ub, _nb = await e2e_fresh_room_allocator("room-isolation-b") logger.info("Room A: %s, Room B: %s", room_a_id, room_b_id) # Create adapter and agent (single agent, two rooms) From 485e15d08f28bc93193ef06b72fa1ca3a13e2ae1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 13:17:37 +0300 Subject: [PATCH 74/90] test(e2e): reword room-isolation prompt as a "note" not a "secret code" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "secret code → recall it" prompt reads as a credential/embedded directive and gets refused even by Sonnet 4.6, which is unrelated to what the test checks (cross-room context isolation). Frame the payload as a neutral "note" so a strong model echoes it back. Validated on claude-sonnet-4-6 across the agno, anthropic, and claude_sdk adapters. Refresh the now-stale agno factory caveat to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/adapters/conftest.py | 16 +++++++-------- tests/e2e/scenarios/test_room_isolation.py | 24 +++++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/tests/e2e/adapters/conftest.py b/tests/e2e/adapters/conftest.py index 59f2da793..e7d237727 100644 --- a/tests/e2e/adapters/conftest.py +++ b/tests/e2e/adapters/conftest.py @@ -112,14 +112,14 @@ def create_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: Use a strong instruction-following model via ``E2E_ANTHROPIC_MODEL`` (e.g. ``claude-sonnet-4-6``). Cheap/small models (e.g. Haiku) refuse the suite's crafted trigger prompts as prompt-injection; Sonnet 4.6 clears - ``test_tool_execution_send_message[agno]`` (echo a code word). - - Caveat: ``test_agents_in_different_rooms_isolated[agno]`` uses a - "remember this secret code → recall it" prompt that even Sonnet 4.6 refuses - ("I don't follow embedded directives"). History rehydration is fine (the - model sees the prior turn); the prompt shape itself trips safety reflexes, - so that case is not fixable by model choice alone — it needs the trigger - prompt reworded. + ``test_tool_execution_send_message[agno]`` (echo a code word) and + ``test_agents_in_different_rooms_isolated[agno]``. + + Note: the room-isolation trigger prompts are framed as a neutral "note" + rather than a "secret code". A "secret code → recall it" prompt reads as a + credential/embedded directive and gets refused even by Sonnet 4.6, which is + unrelated to isolation; the neutral wording avoids that false failure. See + ``tests/e2e/scenarios/test_room_isolation.py``. """ _require_anthropic_key() from agno.agent import Agent as AgnoAgent diff --git a/tests/e2e/scenarios/test_room_isolation.py b/tests/e2e/scenarios/test_room_isolation.py index f2bce27e5..2a0ac80b0 100644 --- a/tests/e2e/scenarios/test_room_isolation.py +++ b/tests/e2e/scenarios/test_room_isolation.py @@ -52,15 +52,19 @@ async def test_agents_in_different_rooms_isolated( ): """Agents in different rooms don't see each other's context. - Room A: Send "The code is " - Room B: Send "The code is " - Room A: Ask "What's the code?" -> Assert unique_a, not unique_b - Room B: Ask "What's the code?" -> Assert unique_b, not unique_a + Room A: Send "Remember this note: " + Room B: Send "Remember this note: " + Room A: Ask "What was the note?" -> Assert unique_a, not unique_b + Room B: Ask "What was the note?" -> Assert unique_b, not unique_a + + Wording note: the payload is framed as a "note", not a "secret code". + Models reliably refuse to repeat back a "code" (it reads as a credential), + which is unrelated to isolation; a neutral noun avoids that false failure. Uses fresh rooms per run (via e2e_fresh_room_allocator) so no stale history accumulates — otherwise a reused room bloats the rehydrated - context into timeouts or surfaces old codes on recall. Unique per-run - keywords additionally guard against any cross-run confusion. + context into timeouts. Unique per-run keywords additionally guard against + any cross-run confusion. """ adapter_name, factory = adapter_entry timeout = e2e_config.e2e_timeout @@ -103,7 +107,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_a_id, - f"Remember: the secret code is {code_a}. Confirm you remember it.", + f"Remember this note: {code_a}. Confirm you remember it.", agent_name, agent_id, ) @@ -115,7 +119,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_b_id, - f"Remember: the secret code is {code_b}. Confirm you remember it.", + f"Remember this note: {code_b}. Confirm you remember it.", agent_name, agent_id, ) @@ -135,7 +139,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_a_id, - "What is the secret code? Reply with just the code word.", + "What was the note? Reply with just it.", agent_name, agent_id, ) @@ -147,7 +151,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_b_id, - "What is the secret code? Reply with just the code word.", + "What was the note? Reply with just it.", agent_name, agent_id, ) From b1564e34f3be5021b5cb12504a4cf26f12510fc3 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 20 Jun 2026 14:35:47 +0300 Subject: [PATCH 75/90] test(e2e): add noisy busy-room scenario across adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-party room (agent + user + second agent) flooded with chatter addressed to other participants. Verifies two things for every adapter: - Needle-in-haystack recall: a seeded "project id" buried under decoy chatter is recalled correctly, with no decoy bleed-through and no timeout on the bloated history. - Selective silence: the agent runs an inference per message (the preprocessor only filters its own messages) but must stay silent on chatter not addressed to it. Asserted with a liveness probe rather than an absence-of-reply wait — because a room's messages are processed in order, the probe answer arriving proves the agent worked past all the noise, so collecting every reply up to it makes the count meaningful (exactly one => silent on the cross-talk). Helpers: add send_agent_message (agent-side message send) and a stop_substring param on listening_for_room_activity so the probe sentinel can bound the collection window. Decoy tokens use distinct word stems so they can't be substrings of the needle (e.g. a "C_" decoy matched the tail of "...ANTHROPIC_"). Validated on claude-sonnet-4-6: agno, anthropic, claude_sdk, langgraph, pydantic_ai all pass. crewai collects but runs only in the dev-crewai env. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- tests/e2e/helpers/__init__.py | 2 + tests/e2e/helpers/messaging.py | 58 ++++- tests/e2e/scenarios/test_noisy_busy_room.py | 251 ++++++++++++++++++++ 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/scenarios/test_noisy_busy_room.py diff --git a/AGENTS.md b/AGENTS.md index 6712353f7..5d1d8c2dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -313,7 +313,7 @@ tests/ ├── integration/ # Real API tests (skipped in CI) ├── e2e/ # End-to-end tests (requires live platform + LLM keys) │ ├── adapters/ # Per-adapter smoke & tool execution tests -│ └── scenarios/ # Cross-cutting scenarios (context persistence, room isolation) +│ └── scenarios/ # Cross-cutting scenarios (context persistence, room isolation, noisy busy room) └── conftest.py # Shared fixtures ``` diff --git a/tests/e2e/helpers/__init__.py b/tests/e2e/helpers/__init__.py index 92b8c5161..2ab36eeb7 100644 --- a/tests/e2e/helpers/__init__.py +++ b/tests/e2e/helpers/__init__.py @@ -20,6 +20,7 @@ listening_for_room_activity, run_smoke_test, run_tool_execution_test, + send_agent_message, send_and_wait_for_reply, send_trigger_message, ) @@ -37,6 +38,7 @@ "run_smoke_test", "run_tool_execution_test", "running_agent", + "send_agent_message", "send_and_wait_for_reply", "send_trigger_message", ] diff --git a/tests/e2e/helpers/messaging.py b/tests/e2e/helpers/messaging.py index f192f3ce6..5b5e87319 100644 --- a/tests/e2e/helpers/messaging.py +++ b/tests/e2e/helpers/messaging.py @@ -106,6 +106,48 @@ async def send_trigger_message( return message_id +async def send_agent_message( + agent_client: AsyncRestClient, + room_id: str, + content: str, + mention_name: str, + mention_id: str, +) -> str: + """Send a message into a room **as an agent**, @mentioning a target. + + The agent-side mirror of :func:`send_trigger_message` (which sends as the + user). Used to produce multi-party "noise" — e.g. a second agent posting + chatter addressed to the user — without running that agent's own loop: we + only post via its REST client, so the message never cascades into an + inference on the sender. The @mention satisfies the platform's + "at least one mention" requirement and routes the message to *mention_id*, + not to the agent under test. + + Args: + agent_client: REST API client (the **sending agent's** credentials). + room_id: Chat room to send the message in. + content: Message content. + mention_name: Name of the participant to @mention. + mention_id: ID of the participant to @mention. + + Returns: + The message ID of the sent message. + """ + message_content = f"@{mention_name} {content}" + response = await agent_client.agent_api_messages.create_agent_chat_message( + room_id, + message=ChatMessageRequest( + content=message_content, + mentions=[Mention(id=mention_id, name=mention_name)], + ), + ) + message_id = response.data.id + logger.info( + "Agent sent message %s to room %s: %s", message_id, room_id, content[:80] + ) + return message_id + + @asynccontextmanager async def listening_for_agent_responses( ws_client: WebSocketClient | TrackingWebSocketClient, @@ -185,6 +227,7 @@ async def listening_for_room_activity( message_types: tuple[str, ...] = ("text",), sender_id: str | None = None, min_messages: int = 1, + stop_substring: str | None = None, raise_on_timeout: bool = False, ) -> AsyncGenerator[Callable[[], Awaitable[list[MessageCreatedPayload]]], None]: """Subscribe to a room and collect agent activity matching a filter. @@ -210,15 +253,23 @@ async def listening_for_room_activity( message_types: Message types to collect (default text only). sender_id: If set, only collect activity from this sender. min_messages: Minimum matching messages before ``wait()`` returns. + stop_substring: If set, ``wait()`` also completes as soon as a collected + payload's content contains this substring (case-insensitive), in + addition to the *min_messages* rule. Useful for a liveness-probe + sentinel: the returned list is then exactly the agent's replies from + subscription through the probe answer, so their *count* is + meaningful (e.g. exactly one ⇒ the agent answered only the probe). raise_on_timeout: If True, ``wait()`` raises ``TimeoutError`` instead of returning partial results. Yields: An async callable that blocks until *min_messages* matching messages - arrive (or *timeout* elapses) and returns the collected payloads. + arrive (or a *stop_substring* match, or *timeout* elapses) and returns + the collected payloads. """ received: list[MessageCreatedPayload] = [] event = asyncio.Event() + stop_needle = stop_substring.lower() if stop_substring is not None else None async def handler(payload: MessageCreatedPayload) -> None: if payload.sender_type != "Agent" or payload.message_type not in message_types: @@ -233,7 +284,10 @@ async def handler(payload: MessageCreatedPayload) -> None: room_id, payload.content[:80], ) - if len(received) >= min_messages: + matched_stop = ( + stop_needle is not None and stop_needle in payload.content.lower() + ) + if len(received) >= min_messages or matched_stop: event.set() await ws_client.join_chat_room_channel(room_id, handler) diff --git a/tests/e2e/scenarios/test_noisy_busy_room.py b/tests/e2e/scenarios/test_noisy_busy_room.py new file mode 100644 index 000000000..0e0a67b98 --- /dev/null +++ b/tests/e2e/scenarios/test_noisy_busy_room.py @@ -0,0 +1,251 @@ +"""E2E test for an agent in a noisy, busy, multi-party room. + +The room-isolation scenario deliberately uses *fresh* rooms so accumulated +history can't bloat rehydration into timeouts. This scenario covers the +opposite case: a room that is genuinely noisy — three participants and a burst +of chatter, most of it addressed to *someone else* — and verifies the agent +still behaves correctly. + +Two properties are checked together, for every adapter: + +1. Needle-in-haystack recall — a target fact ("project id") is seeded, then + buried under distractor chatter carrying decoy values. When asked, the agent + must recall the seeded fact, not a decoy, and must not time out on the busy + history. +2. Selective silence — the distractor chatter is addressed to other + participants. The preprocessor delivers every room message to the agent + (it only filters the agent's own messages), so the agent runs an inference + per message but must stay silent on chatter not directed at it. + +The silence check uses a *liveness probe* rather than waiting for "no answer" +(which can't tell silent-on-purpose from slow/dead): after the noise we ask the +agent an unrelated direct question. Because a room's messages are processed in +order, the probe answer arriving proves the agent already worked past every +noise message — so if it had replied to any, that reply would have arrived +first. Collecting every reply from the flood through the probe answer makes the +*count* meaningful: exactly one (the probe answer) means it stayed silent. + +Run with: + E2E_TESTS_ENABLED=true uv run pytest tests/e2e/scenarios/test_noisy_busy_room.py -v -s --no-cov +""" + +from __future__ import annotations + +import logging +import uuid + +import pytest +from band_rest import AsyncRestClient +from band_rest.types import ParticipantRequest + +from band.agent import Agent + +from tests.e2e.adapters.conftest import AdapterFactory +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e +from tests.e2e.helpers import ( + TrackingWebSocketClient, + assert_content_contains, + assert_no_content_contains, + listening_for_agent_responses, + listening_for_room_activity, + log_banner, + log_step, + send_agent_message, + send_trigger_message, +) + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +@requires_e2e +class TestNoisyBusyRoom: + """An agent must recall the right fact and stay silent on cross-talk.""" + + @pytest.mark.flaky(reruns=2) + @pytest.mark.timeout(300) + async def test_recall_and_silence_in_noisy_room( + self, + e2e_config: E2ESettings, + ws_client: TrackingWebSocketClient, + adapter_entry: tuple[str, AdapterFactory], + api_client: AsyncRestClient, + e2e_fresh_room_allocator: RoomAllocator, + e2e_agent_info: tuple[str, str], + e2e_session_client_2: AsyncRestClient, + e2e_agent_info_2: tuple[str, str], + ): + """Recall a buried fact and ignore chatter addressed to others. + + Wording note: the seeded fact is a neutral "project id", not a "secret + code" — models refuse to repeat back a credential-shaped value, a false + failure unrelated to what this test checks. + """ + adapter_name, factory = adapter_entry + agent_id, agent_name = e2e_agent_info + agent_2_id, agent_2_name = e2e_agent_info_2 + timeout = e2e_config.e2e_timeout + # The agent processes the room's messages one at a time, so the probe + # answer only arrives after it has chewed through every noise message. + # Give that window room for several sequential inferences. + flood_timeout = timeout * 3 + + # Per-run tokens so cross-run history can't make an assertion pass (or + # fail) by coincidence; adapter-prefixed to keep the transcript clear. + # Decoy stems are distinct whole words (not single letters) so none can + # be a substring of the needle — e.g. the needle ends in "...ANTHROPIC_ + # ", which a "C_" decoy would falsely match. + run_id = uuid.uuid4().hex[:6] + needle = f"PROJECT_{adapter_name.upper()}_{run_id}" + weather = f"WEATHER_{run_id}" + color = f"COLOR_{run_id}" + build = f"BUILD_{run_id}" + decoys = (weather, color, build) + live = f"LIVE_{run_id}" + + log_banner(f"[{adapter_name}] Noisy busy room — recall + selective silence") + + # --- Phase 1: multi-party room (agent + user + agent_2) --- + room_id, user_id, user_name = await e2e_fresh_room_allocator("noisy-room") + await api_client.human_api_participants.add_my_chat_participant( + chat_id=room_id, + participant=ParticipantRequest(participant_id=agent_2_id, role="member"), + ) + parts = await api_client.human_api_participants.list_my_chat_participants( + room_id + ) + part_ids = {p.id for p in (parts.data or [])} + assert {agent_id, user_id, agent_2_id} <= part_ids, ( + f"[{adapter_name}] expected a multi-party room with agent, user and " + f"agent_2; participants were {part_ids}" + ) + log_step( + 1, + f"room={room_id} participants=[agent={agent_name}, user={user_name}, " + f"agent_2={agent_2_name}]", + ) + + adapter = factory(e2e_config) + agent = Agent.create( + adapter=adapter, + agent_id=e2e_config.test_agent_id, + api_key=e2e_config.band_api_key, + ws_url=e2e_config.band_ws_url, + rest_url=e2e_config.band_base_url, + ) + + async with agent: + # --- Phase 2: seed the needle (addressed to our agent) --- + async with listening_for_agent_responses( + ws_client, room_id, timeout=timeout, raise_on_timeout=True + ) as wait: + await send_trigger_message( + api_client, + room_id, + f"Please note for later — the project id is {needle}. " + "Just acknowledge.", + agent_name, + agent_id, + ) + ack = await wait() + assert len(ack) >= 1, ( + f"[{adapter_name}] agent never acknowledged the seeded fact" + ) + log_step(2, f"seeded needle={needle}; agent acked ({len(ack)} msg)") + + # --- Phase 3: flood with noise addressed to OTHERS, then probe --- + # min_messages is set above any plausible count so the window ends + # only when the probe answer (sentinel `live`) arrives — letting us + # count every reply the agent made meanwhile. + async with listening_for_room_activity( + ws_client, + room_id, + timeout=flood_timeout, + message_types=("text",), + sender_id=agent_id, + min_messages=99, + stop_substring=live, + raise_on_timeout=True, + ) as wait: + await send_trigger_message( + api_client, + room_id, + f"FYI the weather token is {weather}.", + agent_2_name, + agent_2_id, + ) + await send_agent_message( + e2e_session_client_2, + room_id, + f"Thanks. For the record, the color code is {color}.", + user_name, + user_id, + ) + await send_trigger_message( + api_client, + room_id, + f"Got it. Also the build number is {build}.", + agent_2_name, + agent_2_id, + ) + await send_agent_message( + e2e_session_client_2, + room_id, + "Acknowledged, nothing further.", + user_name, + user_id, + ) + # Unrelated direct question — the liveness probe. + await send_trigger_message( + api_client, + room_id, + f"Reply with just the word {live} and nothing else.", + agent_name, + agent_id, + ) + during_noise = await wait() + + contents = [m.content for m in during_noise] + log_step( + 3, + f"posted 4 noise msgs (decoys {weather}/{color}/{build}); probe={live}", + ) + # Liveness: the probe was answered, so the agent is alive and has + # processed past all the noise. + assert_content_contains(during_noise, live) + # Selective silence: the probe answer is the ONLY thing it said. Any + # reply to the addressed-to-others noise would be an extra entry. + assert len(during_noise) == 1, ( + f"[{adapter_name}] agent should have spoken exactly once (the " + f"probe answer) but said {len(during_noise)}: {contents} — it " + "replied to chatter addressed to other participants" + ) + log_step("assert", f"silent on cross-talk; replies={contents}") + + # --- Phase 4: recall the buried needle (addressed to our agent) --- + async with listening_for_agent_responses( + ws_client, room_id, timeout=timeout, raise_on_timeout=True + ) as wait: + await send_trigger_message( + api_client, + room_id, + "What is the project id? Reply with just it.", + agent_name, + agent_id, + ) + recall = await wait() + assert len(recall) >= 1, ( + f"[{adapter_name}] agent never answered the recall question" + ) + assert_content_contains(recall, needle) + for decoy in decoys: + assert_no_content_contains(recall, decoy) + log_step( + 4, + f"recall reply={[m.content for m in recall]}; " + f"found {needle}, no decoys", + ) + + log_banner( + f"[{adapter_name}] PASSED: busy room — correct recall + selective silence" + ) From f18e79af372d444f2bb94985ce805827cbd144d4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 21 Jun 2026 11:42:20 +0300 Subject: [PATCH 76/90] refactor(agno): run against the given agent directly Remove the deep_copy of the caller's Agno agent and the redundant agent_factory constructor param. The adapter now takes a single required `agent` and configures that exact instance at startup (tool factory, cache_callables, additional_context), taking ownership of it. The runtime agent was only ever built once at startup, never per-room, so there is no cross-room state to isolate; dropping the copy has no effect on per-room isolation (transcripts, the per-run tool factory, and session_id all live off the agent). The instance is still promoted from a held reference in on_started so the "used before on_started" guard and the history/memory-collision warnings keep firing at startup. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/README.md | 16 +- src/band/adapters/agno.py | 68 ++------ tests/adapters/agno/conftest.py | 51 +++--- tests/adapters/agno/helpers.py | 4 +- tests/adapters/agno/test_adapter.py | 181 +++++++--------------- tests/adapters/agno/test_history_guard.py | 18 +-- tests/adapters/agno/test_rehydration.py | 34 ++-- 7 files changed, 125 insertions(+), 247 deletions(-) diff --git a/examples/agno/README.md b/examples/agno/README.md index 6f32688cc..0cb902872 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -46,18 +46,10 @@ agent = Agent.from_config("agno_agent", adapter=adapter) await agent.run() ``` -Passing `agent=` runs the adapter against `agent.deep_copy()` so your instance -stays immutable. If you'd rather skip the deep-copy and hand the adapter a fresh -agent at startup, pass an `agent_factory` instead (provide exactly one): - -```python -adapter = AgnoAdapter( - agent_factory=lambda: AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are helpful.", - ) -) -``` +The adapter runs against the agent instance you pass and takes ownership of it: +at startup it configures that instance for Band (replaces its `tools` with a +per-run factory and appends Band guidance to `additional_context`). Don't reuse +the same instance elsewhere. --- diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 78516d0b4..d30581fb8 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -128,31 +128,22 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): def __init__( self, - agent: AgnoAgent | None = None, + agent: AgnoAgent, *, - agent_factory: Callable[[], AgnoAgent] | None = None, history_converter: AgnoHistoryConverter | None = None, features: AdapterFeatures | None = None, session_id_factory: Callable[[str], str] = lambda room_id: room_id, ) -> None: """Bridge a user-built Agno agent to Band. - Provide **exactly one** of ``agent`` or ``agent_factory``: - - - ``agent``: a fully configured Agno agent. The adapter runs against - ``agent.deep_copy()`` so the caller's instance stays immutable. - - ``agent_factory``: a zero-arg callable returning a fresh Agno agent. - The adapter calls it once at startup, avoiding ``deep_copy()`` - overhead for callers that can cheaply mint a new agent:: - - adapter = AgnoAdapter( - agent_factory=lambda: AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are helpful.", - ) - ) + The adapter runs against the ``agent`` instance you pass **directly**. + At startup it configures that instance for Band -- replacing its + ``tools`` with a per-run factory, disabling ``cache_callables``, and + appending Band guidance to ``additional_context``. The adapter therefore + takes ownership of the agent; do not reuse the same instance elsewhere. Args: + agent: A fully configured Agno agent to bridge to Band. session_id_factory: Maps a Band ``room_id`` to the Agno ``session_id`` used for that room's runs. Defaults to using the ``room_id`` itself, so each Band room is an isolated Agno @@ -162,16 +153,15 @@ def __init__( are keyed by ``room_id``). To keep a single shared session across rooms, pass e.g. ``session_id_factory=lambda _r: "fixed"``. """ - factory = self._resolve_agent_factory(agent, agent_factory) - super().__init__( history_converter=history_converter or AgnoHistoryConverter(), features=features, ) - # The runtime agent is built once at startup (deep-copy or factory call), - # deferring any factory invocation out of __init__. - self._agent_factory = factory + # The caller's agent is used directly. It becomes the runtime agent + # (self._agent) in on_started, where the agent-dependent Band + # configuration is applied -- deferring that work out of __init__. + self._given_agent = agent self._agent: AgnoAgent | None = None self._session_id_factory = session_id_factory @@ -193,32 +183,9 @@ def __init__( # Resolved against the runtime agent in on_started, once it exists. self._agno_manages_history = False - @staticmethod - def _resolve_agent_factory( - agent: AgnoAgent | None, - agent_factory: Callable[[], AgnoAgent] | None, - ) -> Callable[[], AgnoAgent]: - """Pick the single agent factory from the mutually-exclusive args. - - Exactly one of ``agent`` or ``agent_factory`` must be given. When an - ``agent`` is provided, the adapter runs against ``agent.deep_copy`` so the - caller's configured instance stays immutable. - """ - if agent is not None and agent_factory is not None: - raise ValueError( - "AgnoAdapter accepts `agent` or `agent_factory`, not both." - ) - if agent is not None: - return agent.deep_copy - if agent_factory is not None: - return agent_factory - raise ValueError( - "AgnoAdapter requires exactly one of `agent` or `agent_factory`." - ) - @property def agent(self) -> AgnoAgent | None: - """The running Agno agent, initialized in on_started.""" + """The Agno agent this adapter runs against, set in on_started.""" return self._agent def _detect_agno_history(self, agent: AgnoAgent) -> bool: @@ -268,16 +235,15 @@ def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: ) async def on_started(self, agent_name: str, agent_description: str) -> None: - """Build the runtime agent and sync the converter identity. + """Configure the caller's agent for Band and sync the converter identity. - The runtime agent is produced by the factory captured at construction — - either the caller's ``agent.deep_copy`` or a user-supplied ``agent_factory``. - Agent-dependent checks run here (not in ``__init__``) so the factory is - only ever invoked at startup. + The adapter runs against the agent passed at construction. Agent-dependent + checks and the Band configuration (tool factory, ``additional_context``) + run here, not in ``__init__``, so they happen once at startup. """ await super().on_started(agent_name, agent_description) - agent = self._agent_factory() + agent = self._given_agent self._agent = agent self._agno_manages_history = self._detect_agno_history(agent) self._warn_on_memory_collision(agent) diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index 0e37fae65..fb0421362 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -26,11 +26,11 @@ def tools() -> FakeAgentTools: @pytest.fixture -def make_agno_agent() -> Callable[..., tuple[MagicMock, MagicMock]]: - """Factory returning ``(source_agent, copied_agent)`` fakes. +def make_agno_agent() -> Callable[..., MagicMock]: + """Factory returning a configured Agno agent fake. - ``deep_copy()`` returns the copy, mirroring how the adapter runs against a - copy of the developer's agent. The copy's ``arun`` yields ``response``. + The adapter runs against this instance directly, so it carries the + history/memory config the guards read and its ``arun`` yields ``response``. """ def _make( @@ -40,46 +40,35 @@ def _make( add_history_to_context: bool = False, db: object | None = None, response: RunOutput | None = None, - ) -> tuple[MagicMock, MagicMock]: - source = MagicMock(name="source_agent") - source.update_memory_on_run = update_memory_on_run - source.enable_agentic_memory = enable_agentic_memory + ) -> MagicMock: + agent = MagicMock(name="agno_agent") + agent.update_memory_on_run = update_memory_on_run + agent.enable_agentic_memory = enable_agentic_memory # Explicit falsy defaults: a bare MagicMock would expose these as truthy # auto-attributes and spuriously trip the history-management guard. - source.add_history_to_context = add_history_to_context - source.db = db - - copy = MagicMock(name="copied_agent") - copy.add_tool = MagicMock() + agent.add_history_to_context = add_history_to_context + agent.db = db + agent.add_tool = MagicMock() # Real Agno agents default additional_context to None; mirror that. - copy.additional_context = None + agent.additional_context = None # The adapter captures the user's tools at startup, then installs a # callable factory. A bare MagicMock `.tools` is itself callable and would # be mistaken for a user-supplied tools factory, so pin it to a list. - copy.tools = [] - copy.arun = AsyncMock( + agent.tools = [] + agent.arun = AsyncMock( return_value=response if response is not None else RunOutput() ) - # The adapter detects history/memory management against the *runtime* - # agent (this copy), so mirror the source's config here too. Without - # these explicit values the bare MagicMock would expose truthy - # auto-attributes and spuriously trip the guards. - copy.update_memory_on_run = update_memory_on_run - copy.enable_agentic_memory = enable_agentic_memory - copy.add_history_to_context = add_history_to_context - copy.db = db - source.deep_copy = MagicMock(return_value=copy) - return source, copy + return agent return _make @pytest.fixture def make_started_adapter( - make_agno_agent: Callable[..., tuple[MagicMock, MagicMock]], + make_agno_agent: Callable[..., MagicMock], ) -> Callable[..., Awaitable[tuple[AgnoAdapter, MagicMock]]]: """Factory building an adapter past ``on_started``; returns - ``(adapter, copied_agent)``.""" + ``(adapter, agent)``.""" async def _make( response: RunOutput | None = None, @@ -88,14 +77,14 @@ async def _make( add_history_to_context: bool = False, db: object | None = None, ) -> tuple[AgnoAdapter, MagicMock]: - source, copy = make_agno_agent( + agent = make_agno_agent( response=response, add_history_to_context=add_history_to_context, db=db, ) - adapter = AgnoAdapter(source, features=features) + adapter = AgnoAdapter(agent, features=features) await adapter.on_started("TestBot", "desc") - return adapter, copy + return adapter, agent return _make diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py index 45fe9b2bc..0800802f4 100644 --- a/tests/adapters/agno/helpers.py +++ b/tests/adapters/agno/helpers.py @@ -1,8 +1,8 @@ """Shared helpers for the Agno adapter tests. -The adapter never calls an LLM directly: it deep-copies the developer's Agno +The adapter never calls an LLM directly: it configures the developer's Agno agent in ``on_started`` and calls ``agent.arun(...)`` per turn. So the only thing -faked here is the Agno agent (``deep_copy`` / ``add_tool`` / ``arun``); everything +faked here is the Agno agent (``add_tool`` / ``arun``); everything the adapter reads off the run is a real Agno ``RunOutput`` / ``Message`` / ``ToolExecution``. The Band side uses ``FakeAgentTools`` so calls are tracked without a mocking framework. diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 949f71c5b..444b86e53 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -1,11 +1,11 @@ """Agno adapter behavior tests. Conformance already covers init defaults, ``on_started`` name/description, and -generic converter wiring; these tests pin Agno-only behavior: agent deep-copy, -memory-collision warning, per-run Band-tool resolution (the callable-tools -factory + ContextVar binding), strict per-room tool visibility, fallback-send, -emit reporting, transcript persistence, and cleanup. Rehydration of platform -history lives in ``test_rehydration.py``. +generic converter wiring; these tests pin Agno-only behavior: running against +the given agent, memory-collision warning, per-run Band-tool resolution (the +callable-tools factory + ContextVar binding), strict per-room tool visibility, +fallback-send, emit reporting, transcript persistence, and cleanup. Rehydration +of platform history lives in ``test_rehydration.py``. """ from __future__ import annotations @@ -14,7 +14,7 @@ import warnings from datetime import datetime, timezone from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest from agno.agent import Agent as AgnoAgent @@ -60,81 +60,26 @@ def _msg( ) -def _factory_agent_stub() -> MagicMock: - """A fake runtime agent as returned by an ``agent_factory``. - - Unlike the deep-copy path, the factory's agent is used as-is, so it carries - the falsy history/memory defaults and its own ``deep_copy`` to assert the - adapter never copies it. - """ - agent = MagicMock(name="factory_agent") - agent.update_memory_on_run = False - agent.enable_agentic_memory = False - agent.add_history_to_context = False - agent.db = None - agent.additional_context = None - # A bare MagicMock `.tools` is callable and would be mistaken for a - # user-supplied tools factory; pin it to a list. - agent.tools = [] - agent.arun = AsyncMock(return_value=RunOutput()) - agent.deep_copy = MagicMock() - return agent - - class TestOnStarted: - async def test_runs_against_a_deep_copy_not_the_source(self, make_agno_agent): - source, copy = make_agno_agent() - adapter = AgnoAdapter(source) + async def test_runs_against_the_given_agent(self, make_agno_agent): + agent = make_agno_agent() + adapter = AgnoAdapter(agent) await adapter.on_started("TestBot", "desc") - source.deep_copy.assert_called_once() - assert adapter.agent is copy - assert adapter.agent is not source + # The adapter uses the caller's instance directly, no copy. + assert adapter.agent is agent async def test_syncs_converter_identity(self, make_started_adapter): adapter, _ = await make_started_adapter() assert adapter.history_converter._agent_name == "TestBot" - -class TestAgentFactory: - """``agent_factory`` mints the runtime agent at startup without deep_copy().""" - - def test_factory_not_called_in_init(self): - factory = MagicMock(name="agent_factory") - - AgnoAdapter(agent_factory=factory) - - factory.assert_not_called() - - async def test_factory_called_once_in_on_started(self): - runtime_agent = _factory_agent_stub() - factory = MagicMock(name="agent_factory", return_value=runtime_agent) - adapter = AgnoAdapter(agent_factory=factory) - - await adapter.on_started("TestBot", "desc") - - factory.assert_called_once_with() - - async def test_factory_agent_used_directly_not_deep_copied(self): - runtime_agent = _factory_agent_stub() - adapter = AgnoAdapter(agent_factory=lambda: runtime_agent) - - await adapter.on_started("TestBot", "desc") - - assert adapter.agent is runtime_agent - # The factory's agent is used as-is; the adapter must not deep_copy it. - runtime_agent.deep_copy.assert_not_called() - - async def test_factory_built_adapter_runs_the_agent_and_replies(self, tools): - # End-to-end through the factory path: the factory's agent must be the - # one actually run on a message, and its output delivered to the room. - runtime_agent = _factory_agent_stub() - runtime_agent.arun = AsyncMock( - return_value=RunOutput(content="hi from factory") - ) - adapter = AgnoAdapter(agent_factory=lambda: runtime_agent) + async def test_runs_the_agent_and_replies(self, make_agno_agent, tools): + # End-to-end: the given agent must be the one actually run on a message, + # and its output delivered to the room. + agent = make_agno_agent(response=RunOutput(content="hi there")) + adapter = AgnoAdapter(agent) await adapter.on_started("TestBot", "desc") await adapter.on_message( @@ -147,18 +92,8 @@ async def test_factory_built_adapter_runs_the_agent_and_replies(self, tools): room_id="room-1", ) - runtime_agent.arun.assert_awaited_once() - tools.assert_message_sent(content="hi from factory", mentions=["user-1"]) - - def test_neither_agent_nor_factory_raises(self): - with pytest.raises(ValueError, match="exactly one"): - AgnoAdapter() - - def test_both_agent_and_factory_raises(self, make_agno_agent): - source, _ = make_agno_agent() - - with pytest.raises(ValueError, match="not both"): - AgnoAdapter(source, agent_factory=lambda: source) + agent.arun.assert_awaited_once() + tools.assert_message_sent(content="hi there", mentions=["user-1"]) class TestMemoryCollisionWarning: @@ -167,9 +102,9 @@ class TestMemoryCollisionWarning: async def test_warns_on_update_memory_on_run_with_memory_capability( self, make_agno_agent ): - source, _ = make_agno_agent(update_memory_on_run=True) + agent = make_agno_agent(update_memory_on_run=True) adapter = AgnoAdapter( - source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + agent, features=AdapterFeatures(capabilities={Capability.MEMORY}) ) with pytest.warns(UserWarning, match="update_memory_on_run"): @@ -178,19 +113,17 @@ async def test_warns_on_update_memory_on_run_with_memory_capability( async def test_warns_on_agentic_memory_with_memory_capability( self, make_agno_agent ): - source, _ = make_agno_agent(enable_agentic_memory=True) + agent = make_agno_agent(enable_agentic_memory=True) adapter = AgnoAdapter( - source, features=AdapterFeatures(capabilities={Capability.MEMORY}) + agent, features=AdapterFeatures(capabilities={Capability.MEMORY}) ) with pytest.warns(UserWarning, match="enable_agentic_memory"): await adapter.on_started("TestBot", "desc") async def test_no_warning_without_memory_capability(self, make_agno_agent): - source, _ = make_agno_agent( - update_memory_on_run=True, enable_agentic_memory=True - ) - adapter = AgnoAdapter(source) # no MEMORY capability -> no collision + agent = make_agno_agent(update_memory_on_run=True, enable_agentic_memory=True) + adapter = AgnoAdapter(agent) # no MEMORY capability -> no collision with warnings.catch_warnings(): warnings.simplefilter("error") @@ -264,9 +197,9 @@ async def test_user_tools_are_reincluded(self, make_agno_agent): # Replacing agent.tools with our factory must not drop the user's own # tools; they are re-included alongside the room's Band tools. user_tool = object() - source, copy = make_agno_agent() - copy.tools = [user_tool] - adapter = AgnoAdapter(source) + agent = make_agno_agent() + agent.tools = [user_tool] + adapter = AgnoAdapter(agent) await adapter.on_started("TestBot", "desc") tools = SchemaTools([openai_tool_schema("band_send_message")]) @@ -328,10 +261,10 @@ async def test_guidance_injected_at_startup_before_any_message( self, make_started_adapter ): # Band guidance is injected in on_started, not lazily on first message. - adapter, copy = await make_started_adapter() + adapter, agent = await make_started_adapter() - assert isinstance(copy.additional_context, str) - assert "## Environment" in copy.additional_context + assert isinstance(agent.additional_context, str) + assert "## Environment" in agent.additional_context class TestBandEntrypointBinding: @@ -610,8 +543,8 @@ async def test_no_thought_for_blank_reasoning( class TestPersistAndAccumulate: def test_persist_keeps_only_conversation_roles(self, make_agno_agent): - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) response = RunOutput( messages=[ Message(role="system", content="instructions"), @@ -633,8 +566,8 @@ def test_bootstrap_seeds_committed_transcript_from_history( # Bootstrap seeds the committed transcript from rehydrated history. The # returned run input is that seed plus this turn's live message, but # building it must NOT push the live message into the committed store. - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) seed = [Message(role="user", content="earlier")] run_input_msgs = adapter._build_run_input( @@ -658,8 +591,8 @@ def test_build_run_input_does_not_mutate_committed_transcript( ): # A non-bootstrap turn reads the committed transcript but never writes to # it; the store is only ever advanced by _persist_turn after a run. - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) adapter._message_history["room-1"] = [Message(role="user", content="committed")] adapter._build_run_input( @@ -680,11 +613,11 @@ async def test_failed_turn_leaves_no_residue_in_next_run_input( ): # Turn 1 raises mid-run; turn 2 succeeds. The injected system/user # messages from the failed turn must not survive into turn 2's input. - source, copy = make_agno_agent() - copy.arun = AsyncMock( + agent = make_agno_agent() + agent.arun = AsyncMock( side_effect=[RuntimeError("boom"), RunOutput(content="ok")] ) - adapter = AgnoAdapter(source) + adapter = AgnoAdapter(agent) await adapter.on_started("TestBot", "desc") first = _msg("room-1", "first question", msg_id="m1") @@ -710,7 +643,7 @@ async def test_failed_turn_leaves_no_residue_in_next_run_input( room_id="room-1", ) - contents = [m.content for m in run_input(copy)] + contents = [m.content for m in run_input(agent)] # No residue from the failed turn 1. assert not any("P1-participants" in c for c in contents) assert not any("C1-contacts" in c for c in contents) @@ -722,8 +655,8 @@ async def test_failed_turn_leaves_no_residue_in_next_run_input( class TestOnCleanup: async def test_drops_room_transcript(self, make_agno_agent): - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) adapter._message_history["room-1"] = [Message(role="user", content="hi")] await adapter.on_cleanup("room-1") @@ -731,16 +664,16 @@ async def test_drops_room_transcript(self, make_agno_agent): assert "room-1" not in adapter._message_history async def test_unknown_room_is_noop(self, make_agno_agent): - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) await adapter.on_cleanup("never-seen") # must not raise class TestUsedBeforeStarted: async def test_run_agent_before_on_started_raises(self, make_agno_agent): - source, _ = make_agno_agent() - adapter = AgnoAdapter(source) + agent = make_agno_agent() + adapter = AgnoAdapter(agent) with pytest.raises(RuntimeError, match="before on_started"): await adapter._run_agent( @@ -750,7 +683,7 @@ async def test_run_agent_before_on_started_raises(self, make_agno_agent): class TestSessionIsolation: async def test_arun_uses_room_id_as_session_id(self, make_started_adapter): - adapter, copy = await make_started_adapter() + adapter, agent = await make_started_adapter() await adapter.on_message( _msg("room-A", "hi"), @@ -762,11 +695,11 @@ async def test_arun_uses_room_id_as_session_id(self, make_started_adapter): room_id="room-A", ) - assert copy.arun.await_args.kwargs["session_id"] == "room-A" + assert agent.arun.await_args.kwargs["session_id"] == "room-A" async def test_custom_session_id_factory_is_used(self, make_agno_agent): - source, copy = make_agno_agent() - adapter = AgnoAdapter(source, session_id_factory=lambda room: f"sess::{room}") + agent = make_agno_agent() + adapter = AgnoAdapter(agent, session_id_factory=lambda room: f"sess::{room}") await adapter.on_started("TestBot", "desc") await adapter.on_message( @@ -779,12 +712,12 @@ async def test_custom_session_id_factory_is_used(self, make_agno_agent): room_id="room-A", ) - assert copy.arun.await_args.kwargs["session_id"] == "sess::room-A" + assert agent.arun.await_args.kwargs["session_id"] == "sess::room-A" async def test_two_rooms_get_isolated_sessions_and_inputs( self, make_started_adapter ): - adapter, copy = await make_started_adapter() + adapter, agent = await make_started_adapter() await adapter.on_message( _msg("room-A", "alpha-secret"), @@ -805,7 +738,7 @@ async def test_two_rooms_get_isolated_sessions_and_inputs( room_id="room-B", ) - calls = copy.arun.await_args_list + calls = agent.arun.await_args_list assert calls[0].kwargs["session_id"] == "room-A" assert calls[1].kwargs["session_id"] == "room-B" @@ -910,8 +843,8 @@ class TestRunFailureReporting: async def test_emits_generic_error_event_and_reraises( self, make_started_adapter, tools ): - adapter, copy = await make_started_adapter() - copy.arun.side_effect = RuntimeError("db dsn leaked: secret-token") + adapter, agent = await make_started_adapter() + agent.arun.side_effect = RuntimeError("db dsn leaked: secret-token") with pytest.raises(RuntimeError): await adapter.on_message( @@ -936,8 +869,8 @@ async def test_emits_generic_error_event_and_reraises( async def test_error_event_failure_does_not_mask_original( self, make_started_adapter ): - adapter, copy = await make_started_adapter() - copy.arun.side_effect = RuntimeError("boom") + adapter, agent = await make_started_adapter() + agent.arun.side_effect = RuntimeError("boom") class _FailingEventTools(FakeAgentTools): async def send_event(self, *args: Any, **kwargs: Any) -> dict[str, Any]: diff --git a/tests/adapters/agno/test_history_guard.py b/tests/adapters/agno/test_history_guard.py index d206fc17e..4b3894f16 100644 --- a/tests/adapters/agno/test_history_guard.py +++ b/tests/adapters/agno/test_history_guard.py @@ -26,8 +26,8 @@ class TestDetection: async def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): - source, _ = make_agno_agent(add_history_to_context=True, db=object()) - adapter = AgnoAdapter(source) + agent = make_agno_agent(add_history_to_context=True, db=object()) + adapter = AgnoAdapter(agent) # Detection runs against the runtime agent at startup, not in __init__. with pytest.warns(UserWarning, match="manages its own conversation history"): @@ -46,10 +46,8 @@ async def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent async def test_no_guard_unless_both_set( self, make_agno_agent, add_history_to_context, db ): - source, _ = make_agno_agent( - add_history_to_context=add_history_to_context, db=db - ) - adapter = AgnoAdapter(source) + agent = make_agno_agent(add_history_to_context=add_history_to_context, db=db) + adapter = AgnoAdapter(agent) with warnings.catch_warnings(): warnings.simplefilter("error") # any history warning would fail here @@ -71,7 +69,7 @@ async def test_bootstrap_run_input_omits_rehydrated_history( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter( + adapter, agent = await make_started_adapter( RunOutput(content="ack"), add_history_to_context=True, db=object() ) @@ -84,7 +82,7 @@ async def test_bootstrap_run_input_omits_rehydrated_history( ) ) - msgs = run_input(copy) + msgs = run_input(agent) # Only the participants line and the current message — no rehydrated turns. assert [m.content for m in msgs] == [ "[System]: Alice and Bob are here", @@ -101,7 +99,7 @@ async def test_second_turn_does_not_carry_over_band_transcript( Message(role="assistant", content="a1"), ], ) - adapter, copy = await make_started_adapter( + adapter, agent = await make_started_adapter( turn, add_history_to_context=True, db=object() ) @@ -114,7 +112,7 @@ async def test_second_turn_does_not_carry_over_band_transcript( # The follow-up turn sends only the current message: Agno supplies prior # turns from its own database, so Band must not replay turn 1. - msgs = run_input(copy) + msgs = run_input(agent) assert [m.content for m in msgs] == [sample_platform_message.format_for_llm()] diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py index 73a37ec2c..12a3d368f 100644 --- a/tests/adapters/agno/test_rehydration.py +++ b/tests/adapters/agno/test_rehydration.py @@ -51,13 +51,13 @@ async def test_all_message_kinds_become_the_right_messages( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter(RunOutput(content="ack")) + adapter, agent = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) ) - msgs = run_input(copy) + msgs = run_input(agent) assert [m.role for m in msgs] == [ "user", # other participant text "assistant", # own-agent text @@ -88,13 +88,13 @@ async def test_unsupported_kinds_are_dropped( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter(RunOutput(content="ack")) + adapter, agent = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) ) - msgs = run_input(copy) + msgs = run_input(agent) # Only the plain text + current message survive; thought/unknown dropped. assert [m.content for m in msgs] == [ "[Alice]: hello", @@ -108,20 +108,20 @@ async def test_history_is_from_history_but_current_message_is_live( [platform_msg("h1", "hi", sender_name="Alice")], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter(RunOutput(content="ack")) + adapter, agent = await make_started_adapter(RunOutput(content="ack")) await adapter.on_event( make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) ) - msgs = run_input(copy) + msgs = run_input(agent) assert all(m.from_history for m in msgs[:-1]) # rehydrated context assert not msgs[-1].from_history # the message to actually answer async def test_participants_and_contacts_injected_before_current_message( self, make_started_adapter, sample_platform_message ): - adapter, copy = await make_started_adapter(RunOutput(content="ok")) + adapter, agent = await make_started_adapter(RunOutput(content="ok")) await adapter.on_event( make_agent_input( @@ -133,7 +133,7 @@ async def test_participants_and_contacts_injected_before_current_message( ) ) - msgs = run_input(copy) + msgs = run_input(agent) assert [m.content for m in msgs] == [ "[System]: Alice and Bob are here", "[System]: Carol is now a contact", @@ -158,7 +158,7 @@ async def test_current_message_excluded_from_history_then_answered( assert len(raw) == 1 assert all(current.content not in h["content"] for h in raw) - adapter, copy = await make_started_adapter( + adapter, agent = await make_started_adapter( RunOutput(content="here is your answer") ) @@ -169,7 +169,7 @@ async def test_current_message_excluded_from_history_then_answered( tools.assert_message_sent( content="here is your answer", mentions=[current.sender_id] ) - msgs = run_input(copy) + msgs = run_input(agent) formatted = current.format_for_llm() assert sum(1 for m in msgs if m.content == formatted) == 1 assert msgs[-1].content == formatted @@ -188,7 +188,7 @@ async def test_answers_unanswered_message_on_restart_bootstrap( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter(RunOutput(content="fresh answer")) + adapter, agent = await make_started_adapter(RunOutput(content="fresh answer")) await adapter.on_event( make_agent_input( @@ -196,11 +196,11 @@ async def test_answers_unanswered_message_on_restart_bootstrap( ) ) - copy.arun.assert_awaited_once() + agent.arun.assert_awaited_once() tools.assert_message_sent( content="fresh answer", mentions=[sample_platform_message.sender_id] ) - assert run_input(copy)[-1].content == sample_platform_message.format_for_llm() + assert run_input(agent)[-1].content == sample_platform_message.format_for_llm() async def test_trailing_unanswered_user_turns_are_preserved( self, make_started_adapter, sample_platform_message, tools @@ -215,7 +215,7 @@ async def test_trailing_unanswered_user_turns_are_preserved( ], exclude_id=sample_platform_message.id, ) - adapter, copy = await make_started_adapter(RunOutput(content="answering all")) + adapter, agent = await make_started_adapter(RunOutput(content="answering all")) await adapter.on_event( make_agent_input( @@ -223,7 +223,7 @@ async def test_trailing_unanswered_user_turns_are_preserved( ) ) - msgs = run_input(copy) + msgs = run_input(agent) assert [m.role for m in msgs] == ["user", "user", "user", "user"] assert [m.content for m in msgs[:3]] == [ "[Alice]: first", @@ -246,7 +246,7 @@ async def test_persisted_transcript_feeds_the_next_turn( Message(role="assistant", content="a1"), ], ) - adapter, copy = await make_started_adapter(turn) + adapter, agent = await make_started_adapter(turn) await adapter.on_event( make_agent_input(sample_platform_message, [], is_session_bootstrap=True) @@ -255,7 +255,7 @@ async def test_persisted_transcript_feeds_the_next_turn( make_agent_input(sample_platform_message, [], is_session_bootstrap=False) ) - msgs = run_input(copy) # the second (follow-up) turn's input + msgs = run_input(agent) # the second (follow-up) turn's input assert [m.content for m in msgs[:2]] == ["[Alice]: q1", "a1"] assert msgs[-1].content == sample_platform_message.format_for_llm() assert len(msgs) == 3 From 089ec3364b97f7e5da6d0fc2b7c6f6f1b049c519 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 21 Jun 2026 11:56:34 +0300 Subject: [PATCH 77/90] fix(agno): drop fabricated fallback reply; rely on band_send_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter posted the agent's final plain text itself, addressed to the message sender, whenever the agent did not call band_send_message. This contradicted the contract every other adapter follows (and the base prompt's "plain text output is not delivered"): whether to respond, and to whom, is the LLM's decision via the tool. The fallback also had to guess the recipient from sender_id/sender_name and swallow mention-resolution errors — a forced send the adapter had no reliable address for. Remove _send_reply and align Agno with the tool-only adapters. When the agent finishes without calling band_send_message, log at debug and deliver nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/adapters/agno.py | 89 +++++-------------------- tests/adapters/agno/test_adapter.py | 72 ++++---------------- tests/adapters/agno/test_rehydration.py | 7 -- 3 files changed, 29 insertions(+), 139 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index d30581fb8..8f9345124 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -107,11 +107,11 @@ class AgnoAdapter(SimpleAdapter[AgnoMessages]): "User" throughout this adapter means the SDK integrator who built and configured the Agno agent — never a chat end-user (``sender_type`` "User"). - Note on replies: unlike the other adapters (which deliver only when the - agent calls ``band_send_message``), this adapter falls back to posting the - agent's final text itself, addressed to the message sender, when - ``band_send_message`` was not called. Steer the agent to call the tool when - you need explicit recipients or no auto-reply. + Note on replies: like the other adapters, this one delivers nothing on its + own — the agent must call ``band_send_message`` to communicate. The base + prompt states "plain text output is not delivered"; an agent that only + returns plain text stays silent. It is up to the agent (the LLM) to decide + whether to respond and whom to address. Note on ``Emit.THOUGHTS``: when enabled, the agent's **raw** ``reasoning_content`` is posted to the room as a thought event. This can @@ -318,7 +318,17 @@ async def on_message( ) self._persist_turn(room_id, response) - await self._send_reply(msg, tools, response, room_id=room_id) + + if not any( + _tool_name(execution) == "band_send_message" + for execution in _tool_executions(response) + ): + logger.debug( + "Room %s msg %s: agent did not call band_send_message; " + "nothing delivered", + room_id, + msg.id, + ) async def on_cleanup(self, room_id: str) -> None: """Drop the room's accumulated transcript when the agent leaves.""" @@ -437,73 +447,6 @@ def _persist_turn(self, room_id: str, response: RunOutput) -> None: m for m in response.messages if m.role in _CONVERSATION_ROLES ] - @classmethod - async def _send_reply( - cls, - msg: PlatformMessage, - tools: AgentToolsProtocol, - response: RunOutput, - *, - room_id: str, - ) -> None: - """Send final text unless the agent already posted through Band. - - The shared base prompt tells the agent "plain text output is not - delivered" to steer it toward ``band_send_message`` (proper mentions + - events). This adapter still delivers final text here as a fallback - convenience for agents that return text directly; the fallback is - intentionally not advertised in the prompt. - """ - if any( - _tool_name(execution) == "band_send_message" - for execution in _tool_executions(response) - ): - logger.debug( - "Room %s msg %s: agent replied via band_send_message", room_id, msg.id - ) - return - - text = response.get_content_as_string().strip() - if not text: - logger.debug("Room %s msg %s: agent produced no reply", room_id, msg.id) - return - - # Address the reply to the sender. ``sender_id`` is the primary - # identifier, but it may not match a cached participant (id-space - # mismatch or a stale cache); fall back to the display name. An - # unresolvable mention raises ValueError in mention resolution, which - # would otherwise fail the whole turn — try each candidate and degrade - # to a warning rather than crashing. - candidates = [c for c in (msg.sender_id, msg.sender_name) if c] - for candidate in candidates: - try: - await tools.send_message(text, mentions=[candidate]) - except ValueError as e: - logger.debug( - "Room %s msg %s: mention %r did not resolve: %s", - room_id, - msg.id, - candidate, - e, - ) - else: - logger.info( - "Room %s msg %s: sent reply (%d chars), mention=%s", - room_id, - msg.id, - len(text), - candidate, - ) - return - - logger.warning( - "Room %s msg %s: no resolvable mention for sender %s (%s); reply not delivered", - room_id, - msg.id, - msg.sender_id, - msg.sender_name, - ) - def _capture_user_tools(self, agent: AgnoAgent) -> None: """Capture the user's own tools before installing the room factory. diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 444b86e53..108561602 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -75,9 +75,10 @@ async def test_syncs_converter_identity(self, make_started_adapter): assert adapter.history_converter._agent_name == "TestBot" - async def test_runs_the_agent_and_replies(self, make_agno_agent, tools): - # End-to-end: the given agent must be the one actually run on a message, - # and its output delivered to the room. + async def test_runs_the_given_agent_on_a_message(self, make_agno_agent, tools): + # End-to-end: the given agent must be the one actually run on a message. + # The adapter delivers nothing on its own; plain agent text is not sent + # (only a ``band_send_message`` tool call reaches the room). agent = make_agno_agent(response=RunOutput(content="hi there")) adapter = AgnoAdapter(agent) await adapter.on_started("TestBot", "desc") @@ -93,7 +94,7 @@ async def test_runs_the_agent_and_replies(self, make_agno_agent, tools): ) agent.arun.assert_awaited_once() - tools.assert_message_sent(content="hi there", mentions=["user-1"]) + tools.assert_no_messages_sent() class TestMemoryCollisionWarning: @@ -301,7 +302,11 @@ async def test_errors_outside_any_bound_context(self, tools): class TestReply: - async def test_sends_fallback_text_when_agent_did_not_post( + """The adapter delivers nothing on its own. Like the other adapters, the + agent must call ``band_send_message`` to reach the room; plain agent text is + never auto-sent and the adapter never guesses a recipient.""" + + async def test_no_send_when_agent_returns_only_text( self, make_started_adapter, sample_platform_message, tools ): adapter, _ = await make_started_adapter(RunOutput(content="hello")) @@ -316,11 +321,12 @@ async def test_sends_fallback_text_when_agent_did_not_post( room_id="room-1", ) - tools.assert_message_sent(content="hello", mentions=["user-456"]) + tools.assert_no_messages_sent() - async def test_skips_fallback_when_agent_called_band_send_message( + async def test_no_send_when_agent_called_band_send_message( self, make_started_adapter, sample_platform_message, tools ): + # The tool call itself reaches the room; the adapter adds nothing on top. response = RunOutput( content="hello", tools=[tool_execution("band_send_message")] ) @@ -355,58 +361,6 @@ async def test_no_send_for_empty_content( tools.assert_no_messages_sent() - async def test_reply_falls_back_to_sender_name_when_id_unresolvable( - self, make_started_adapter, sample_platform_message - ): - # sender_id may not match a cached participant (id-space mismatch); the - # reply should retry with the display name rather than failing the turn. - class _IdRejectingTools(FakeAgentTools): - async def send_message(self, content, mentions=None): - if mentions and mentions[0] == sample_platform_message.sender_id: - raise ValueError(f"Unknown participant '{mentions[0]}'") - return await super().send_message(content, mentions=mentions) - - adapter, _ = await make_started_adapter(RunOutput(content="hello")) - tools = _IdRejectingTools() - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_message_sent( - content="hello", mentions=[sample_platform_message.sender_name] - ) - - async def test_reply_does_not_crash_when_no_mention_resolves( - self, make_started_adapter, sample_platform_message - ): - # An unresolvable sender must not fail the whole turn (which would mark - # the inbound message permanently failed). - class _AllRejectingTools(FakeAgentTools): - async def send_message(self, content, mentions=None): - raise ValueError("Unknown participant") - - adapter, _ = await make_started_adapter(RunOutput(content="hello")) - tools = _AllRejectingTools() - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_no_messages_sent() - class TestEmitExecution: async def test_emits_tool_call_and_result_events( diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py index 12a3d368f..1b4ba0948 100644 --- a/tests/adapters/agno/test_rehydration.py +++ b/tests/adapters/agno/test_rehydration.py @@ -166,9 +166,6 @@ async def test_current_message_excluded_from_history_then_answered( make_agent_input(current, raw, is_session_bootstrap=True, tools=tools) ) - tools.assert_message_sent( - content="here is your answer", mentions=[current.sender_id] - ) msgs = run_input(agent) formatted = current.format_for_llm() assert sum(1 for m in msgs if m.content == formatted) == 1 @@ -197,9 +194,6 @@ async def test_answers_unanswered_message_on_restart_bootstrap( ) agent.arun.assert_awaited_once() - tools.assert_message_sent( - content="fresh answer", mentions=[sample_platform_message.sender_id] - ) assert run_input(agent)[-1].content == sample_platform_message.format_for_llm() async def test_trailing_unanswered_user_turns_are_preserved( @@ -230,7 +224,6 @@ async def test_trailing_unanswered_user_turns_are_preserved( "[Bob]: second", "[Alice]: third", ] - tools.assert_message_sent(content="answering all") class TestMultiTurnCarryover: From bc82e8c24a74b1a23a5ca3881c08bcca993d15a7 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 21 Jun 2026 12:51:54 +0300 Subject: [PATCH 78/90] review --- src/band/adapters/agno.py | 203 ++++++++++++++++------------ tests/adapters/agno/conftest.py | 25 +++- tests/adapters/agno/helpers.py | 14 ++ tests/adapters/agno/test_adapter.py | 19 ++- 4 files changed, 168 insertions(+), 93 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 8f9345124..9b73d8363 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -8,8 +8,7 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar -from functools import wraps -from typing import TYPE_CHECKING, Any, ClassVar, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter @@ -26,6 +25,11 @@ try: from agno.models.message import Message + from agno.run.agent import ( + RunOutput, + ToolCallCompletedEvent, + ToolCallStartedEvent, + ) from agno.tools import Toolkit from agno.tools.function import Function from agno.utils.callables import ainvoke_callable_factory, is_callable_factory @@ -37,13 +41,10 @@ if TYPE_CHECKING: from agno.agent import Agent as AgnoAgent - from agno.run.agent import RunOutput + from agno.run.agent import RunOutputEvent logger = logging.getLogger(__name__) -P = ParamSpec("P") -R = TypeVar("R") - # These tools already produce visible room output. _SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) @@ -66,19 +67,6 @@ def _tool_name(execution: Any) -> str: return getattr(execution, "tool_name", None) or "" -def _with_agent( - fn: Callable[Concatenate[Any, AgnoAgent, P], Awaitable[R]], -) -> Callable[Concatenate[Any, P], Awaitable[R]]: - @wraps(fn) - async def wrapper(self: Any, *args: P.args, **kwargs: P.kwargs) -> R: - agent = getattr(self, "_agent", None) - if agent is None: - raise RuntimeError("AgnoAdapter was used before on_started()") - return await fn(self, agent, *args, **kwargs) - - return wrapper - - def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: async def _entrypoint(**kwargs: Any) -> str: active = _current_tools.get() @@ -312,10 +300,6 @@ async def on_message( if Emit.THOUGHTS in self.features.emit: await self._report_thoughts(response, tools, room_id=room_id, msg_id=msg.id) - if Emit.EXECUTION in self.features.emit: - await self._report_tool_executions( - response, tools, room_id=room_id, msg_id=msg.id - ) self._persist_turn(room_id, response) @@ -389,17 +373,24 @@ def _build_run_input( messages.append(Message(role="user", content=msg.format_for_llm())) return messages - @_with_agent async def _run_agent( self, - agent: AgnoAgent, messages: list[Message], tools: AgentToolsProtocol, *, room_id: str, msg_id: str, ) -> RunOutput | None: - """Run the Agno agent with the room's tools bound for this call.""" + """Run the Agno agent with the room's tools bound for this call. + + When ``Emit.EXECUTION`` is enabled the run is streamed so tool_call / + tool_result events are emitted *as each tool runs* (see + :meth:`_run_streamed`), matching the other adapters' live reporting. + Otherwise it runs non-streaming, exactly as before. + """ + agent = self._agent + if agent is None: + raise RuntimeError("AgnoAdapter was used before on_started()") session_id = self._session_id_factory(room_id) logger.debug( "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", @@ -410,7 +401,17 @@ async def _run_agent( ) try: with _bind_room_tools(tools): - response = await agent.arun(input=messages, session_id=session_id) + if Emit.EXECUTION in self.features.emit: + response = await self._run_streamed( + agent, + messages, + tools, + session_id=session_id, + room_id=room_id, + msg_id=msg_id, + ) + else: + response = await agent.arun(input=messages, session_id=session_id) except Exception: # Keep the user-facing payload generic; the full traceback is in the # agent log via logger.exception. Exception text can include DB @@ -435,6 +436,40 @@ async def _run_agent( ) return response + async def _run_streamed( + self, + agent: AgnoAgent, + messages: list[Message], + tools: AgentToolsProtocol, + *, + session_id: str, + room_id: str, + msg_id: str, + ) -> RunOutput | None: + """Stream the run, emitting tool events live, and return the final output. + + ``stream_events=True`` yields a ``ToolCallStartedEvent`` / + ``ToolCallCompletedEvent`` for every tool call (user-configured and + Band), and ``yield_run_output=True`` yields the assembled ``RunOutput`` + last. The ``_current_tools`` binding from :meth:`_run_agent` spans the + whole iteration, since tools execute as the stream is consumed. + """ + final: RunOutput | None = None + async for item in agent.arun( + input=messages, + session_id=session_id, + stream=True, + stream_events=True, + yield_run_output=True, + ): + if isinstance(item, RunOutput): + final = item + else: + await self._emit_stream_event( + item, tools, room_id=room_id, msg_id=msg_id + ) + return final + def _persist_turn(self, room_id: str, response: RunOutput) -> None: """Persist Agno's transcript, keeping only conversation messages. @@ -601,80 +636,80 @@ async def _report_thoughts( "Room %s msg %s: failed to report thought: %s", room_id, msg_id, e ) - async def _report_tool_executions( - self, - response: RunOutput, + @classmethod + async def _emit_stream_event( + cls, + item: RunOutputEvent, tools: AgentToolsProtocol, *, room_id: str, msg_id: str, ) -> None: - """Emit tool_call/tool_result events for reportable executions.""" - executions = [ - execution - for execution in _tool_executions(response) - if _tool_name(execution) not in _SELF_REPORTING_TOOLS - ] - if not executions: - return - - logger.info( - "Room %s msg %s: reporting %d tool execution(s)", - room_id, - msg_id, - len(executions), - ) - for execution in executions: - await self._emit_execution(execution, tools, room_id=room_id, msg_id=msg_id) + """Emit a tool_call / tool_result event for one streamed run event. + + Agno yields a started + completed event (each carrying a + ``ToolExecution``) for every tool call -- user-configured and Band + alike. Self-reporting tools already produce visible room output, so they + are skipped. The completed event carries ``result`` + ``tool_call_error``, + so exactly one tool_result is emitted per call whether it succeeded or + failed; all other events (content deltas, reasoning, ``ToolCallErrorEvent``) + fall through and are ignored. + """ + if ( + isinstance(item, ToolCallStartedEvent) + and (ex := item.tool) is not None + and ex.tool_name not in _SELF_REPORTING_TOOLS + ): + await cls._emit_tool_event( + tools, + "tool_call", + { + "name": ex.tool_name or "", + "args": ex.tool_args or {}, + "tool_call_id": ex.tool_call_id or "", + }, + room_id=room_id, + msg_id=msg_id, + ) + elif ( + isinstance(item, ToolCallCompletedEvent) + and (ex := item.tool) is not None + and ex.tool_name not in _SELF_REPORTING_TOOLS + ): + await cls._emit_tool_event( + tools, + "tool_result", + { + "name": ex.tool_name or "", + "output": str(ex.result or ""), + "tool_call_id": ex.tool_call_id or "", + "is_error": bool(ex.tool_call_error), + }, + room_id=room_id, + msg_id=msg_id, + ) - @classmethod - async def _emit_execution( - cls, - execution: Any, + @staticmethod + async def _emit_tool_event( tools: AgentToolsProtocol, + message_type: str, + payload: dict[str, Any], *, room_id: str, msg_id: str, ) -> None: - """Emit the tool_call + tool_result event pair for one tool execution.""" - tool_call_id = getattr(execution, "tool_call_id", None) or "" - tool_name = getattr(execution, "tool_name", None) or "" - tool_args = getattr(execution, "tool_args", None) or {} - is_error = bool(getattr(execution, "tool_call_error", False)) - result = str(getattr(execution, "result", "") or "") - - logger.debug( - "Room %s msg %s: tool %s(%s) -> %s%s", - room_id, - msg_id, - tool_name, - tool_args, - result[:200], - " [error]" if is_error else "", - ) + """Send one tool event, logging (never raising) on failure.""" + logger.debug("Room %s msg %s: %s %s", room_id, msg_id, message_type, payload) try: await tools.send_event( - content=json.dumps( - {"name": tool_name, "args": tool_args, "tool_call_id": tool_call_id} - ), - message_type="tool_call", - ) - await tools.send_event( - content=json.dumps( - { - "name": tool_name, - "output": result, - "tool_call_id": tool_call_id, - "is_error": is_error, - } - ), - message_type="tool_result", + content=json.dumps(payload), message_type=message_type ) except Exception as e: logger.warning( - "Room %s msg %s: failed to report tool execution %s: %s", + "Room %s msg %s: failed to report %s %s: %s", room_id, msg_id, - tool_name, + message_type, + payload.get("name"), e, ) diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py index fb0421362..c0e5fd9be 100644 --- a/tests/adapters/agno/conftest.py +++ b/tests/adapters/agno/conftest.py @@ -6,6 +6,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -40,6 +41,7 @@ def _make( add_history_to_context: bool = False, db: object | None = None, response: RunOutput | None = None, + events: list[Any] | None = None, ) -> MagicMock: agent = MagicMock(name="agno_agent") agent.update_memory_on_run = update_memory_on_run @@ -55,9 +57,24 @@ def _make( # callable factory. A bare MagicMock `.tools` is itself callable and would # be mistaken for a user-supplied tools factory, so pin it to a list. agent.tools = [] - agent.arun = AsyncMock( - return_value=response if response is not None else RunOutput() - ) + resp = response if response is not None else RunOutput() + if events is None: + # Non-streaming path (Emit.EXECUTION off): `await agent.arun(...)`. + agent.arun = AsyncMock(return_value=resp) + else: + # Streaming path (Emit.EXECUTION on): the adapter iterates + # `agent.arun(stream=True, ...)`, which yields the run events then the + # final RunOutput. A bare MagicMock returns an async iterator without + # awaiting, matching how the adapter consumes the stream. + def _arun(*args: Any, **kwargs: Any) -> Any: + async def _stream() -> Any: + for event in events: + yield event + yield resp + + return _stream() + + agent.arun = MagicMock(side_effect=_arun) return agent return _make @@ -76,11 +93,13 @@ async def _make( features: AdapterFeatures | None = None, add_history_to_context: bool = False, db: object | None = None, + events: list[Any] | None = None, ) -> tuple[AgnoAdapter, MagicMock]: agent = make_agno_agent( response=response, add_history_to_context=add_history_to_context, db=db, + events=events, ) adapter = AgnoAdapter(agent, features=features) await adapter.on_started("TestBot", "desc") diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py index 0800802f4..b2f1d512b 100644 --- a/tests/adapters/agno/helpers.py +++ b/tests/adapters/agno/helpers.py @@ -16,6 +16,7 @@ from agno.models.base import Model from agno.models.message import Message from agno.models.response import ModelResponse, ToolExecution +from agno.run.agent import ToolCallCompletedEvent, ToolCallStartedEvent from band.core.types import ( AgentInput, @@ -42,6 +43,19 @@ def tool_execution( ) +def tool_events(execution: ToolExecution) -> list[Any]: + """The started + completed stream events Agno yields for one tool call. + + Mirrors a streamed ``arun`` (``stream_events=True``): the started event + carries name/args/id; the completed event carries result/error. The same + ``ToolExecution`` instance is used for both, as Agno mutates and re-emits it. + """ + return [ + ToolCallStartedEvent(tool=execution), + ToolCallCompletedEvent(tool=execution), + ] + + class CapturingModel(Model): """A real Agno model that records the messages Agno asks it to respond to. diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 108561602..d8c317cab 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -35,6 +35,7 @@ SchemaTools, openai_tool_schema, run_input, + tool_events, tool_execution, ) @@ -366,11 +367,13 @@ class TestEmitExecution: async def test_emits_tool_call_and_result_events( self, make_started_adapter, sample_platform_message, tools ): - response = RunOutput( - tools=[tool_execution("band_lookup_peers", args={"page": "1"}, result="ok")] + # Streamed run: started + completed events for one tool call. Reporting + # is live (during the run), driven off Agno's native stream events. + events = tool_events( + tool_execution("band_lookup_peers", args={"page": "1"}, result="ok") ) adapter, _ = await make_started_adapter( - response, features=AdapterFeatures(emit={Emit.EXECUTION}) + features=AdapterFeatures(emit={Emit.EXECUTION}), events=events ) await adapter.on_message( @@ -398,9 +401,9 @@ async def test_emits_tool_call_and_result_events( async def test_self_reporting_tools_are_not_re_emitted( self, make_started_adapter, sample_platform_message, tools ): - response = RunOutput(tools=[tool_execution("band_send_message")]) + events = tool_events(tool_execution("band_send_message")) adapter, _ = await make_started_adapter( - response, features=AdapterFeatures(emit={Emit.EXECUTION}) + features=AdapterFeatures(emit={Emit.EXECUTION}), events=events ) await adapter.on_message( @@ -418,8 +421,10 @@ async def test_self_reporting_tools_are_not_re_emitted( async def test_no_events_without_execution_emit( self, make_started_adapter, sample_platform_message, tools ): + # No Emit.EXECUTION -> non-streaming run, no live reporting. The final + # RunOutput still carries executions, but nothing is emitted. response = RunOutput(tools=[tool_execution("band_lookup_peers")]) - adapter, _ = await make_started_adapter(response) # no emit configured + adapter, agent = await make_started_adapter(response) # no emit configured await adapter.on_message( sample_platform_message, @@ -432,6 +437,8 @@ async def test_no_events_without_execution_emit( ) assert tools.events_sent == [] + # Confirms the non-streaming path: a single awaited arun, not a stream. + agent.arun.assert_awaited_once() class TestEmitThoughts: From aa358688e596e774be998f37bb890b165e5ba4b4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 21 Jun 2026 13:10:30 +0300 Subject: [PATCH 79/90] test(e2e): use fresh room for agno thoughts; inline scenario room allocators The thoughts scenario reused a persisted room, so accumulated repeats of the same step-by-step question let Claude skip extended thinking, leaving reasoning_content empty -> no thought event -> a flaky failure. Allocate a fresh room instead, matching the database-restart scenario. Also drop the thin agno_multi_room / agno_thoughts_room / agno_database_room fixture wrappers; each scenario calls the allocator directly (mirroring test_context_persistence), keeping the room name and the "why this allocator" rationale at the call site. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/scenarios/agno/conftest.py | 43 +------------------ .../scenarios/agno/test_database_restart.py | 11 +++-- tests/e2e/scenarios/agno/test_multi_agent.py | 14 +++--- tests/e2e/scenarios/agno/test_thoughts.py | 9 ++-- 4 files changed, 24 insertions(+), 53 deletions(-) diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py index 869336c8c..9866ea3b8 100644 --- a/tests/e2e/scenarios/agno/conftest.py +++ b/tests/e2e/scenarios/agno/conftest.py @@ -22,14 +22,13 @@ import logging from typing import TYPE_CHECKING, Any -import pytest from band_rest import AsyncRestClient from band.core.simple_adapter import SimpleAdapter from tests.conftest_integration import fetch_all_context from tests.e2e.adapters.conftest import _require_anthropic_key -from tests.e2e.settings import E2ESettings, RoomAllocator +from tests.e2e.settings import E2ESettings from tests.e2e.helpers import find_tool_call_in_context, log_step if TYPE_CHECKING: @@ -310,43 +309,3 @@ async def assert_total_reported( f"but it was not found among {len(texts)} text message(s)." ) log_step("assert", f"total {GROCERY_TOTAL:.2f} reported in room") - - -# ============================================================================= -# Room fixtures -# ============================================================================= - - -@pytest.fixture -async def agno_multi_room( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for the multi-agent Agno scenarios. - - Returns (room_id, user_id, user_name). The room starts with Agent A (its - creator) and the User; Agent B is added during the flow. - """ - return await e2e_room_allocator("agno_multi_agent") - - -@pytest.fixture -async def agno_thoughts_room( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for the Agno thoughts scenario.""" - return await e2e_room_allocator("agno_thoughts") - - -@pytest.fixture -async def agno_database_room( - e2e_fresh_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Fresh, uncontaminated room for the db-backed Agno restart scenario. - - Uses the fresh-room allocator (not the reusing one) on purpose: this - scenario disables Band's history rehydration, so the agent's only memory is - Agno's ephemeral db. A reused room's stale "remember X" messages from prior - runs would otherwise be answered on bootstrap and contaminate the recall - assertion with an old secret. - """ - return await e2e_fresh_room_allocator("agno_database_restart") diff --git a/tests/e2e/scenarios/agno/test_database_restart.py b/tests/e2e/scenarios/agno/test_database_restart.py index 37638c0fb..eecd1e441 100644 --- a/tests/e2e/scenarios/agno/test_database_restart.py +++ b/tests/e2e/scenarios/agno/test_database_restart.py @@ -39,7 +39,7 @@ from band_rest import AsyncRestClient from tests.conftest_integration import fetch_all_context -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, @@ -64,13 +64,18 @@ class TestAgnoDatabaseRestart: async def test_db_backed_agent_remembers_after_restart( self, e2e_config: E2ESettings, - agno_database_room: tuple[str, str, str], + e2e_fresh_room_allocator: RoomAllocator, e2e_agent_info: tuple[str, str], e2e_session_client: AsyncRestClient, ws_client: TrackingWebSocketClient, api_client: AsyncRestClient, ) -> None: - room_id, _user_id, _user_name = agno_database_room + # Fresh room (not the reusing allocator): this scenario relies on Agno's + # ephemeral db for memory, so a reused room's stale "remember X" messages + # would contaminate the recall assertion. + room_id, _user_id, _user_name = await e2e_fresh_room_allocator( + "agno_database_restart" + ) agent_id, agent_name = e2e_agent_info timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) run_id = uuid.uuid4().hex[:6] diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py index 3d27bc158..f2af9e046 100644 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ b/tests/e2e/scenarios/agno/test_multi_agent.py @@ -31,7 +31,7 @@ import pytest from band_rest import AsyncRestClient -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_room_activity, @@ -70,7 +70,7 @@ class TestAgnoMultiAgent: async def test_assistant_invites_calculator_for_total( self, e2e_config: E2ESettings, - agno_multi_room: tuple[str, str, str], + e2e_room_allocator: RoomAllocator, e2e_agent_info: tuple[str, str], e2e_agent_info_2: tuple[str, str], e2e_session_client: AsyncRestClient, @@ -83,7 +83,9 @@ async def test_assistant_invites_calculator_for_total( Verifies (by direct REST query) that the calculator's tool ran, the total was reported, and the calculator was removed afterward. """ - room_id, _user_id, _user_name = agno_multi_room + # Reusing allocator (cached by name): both multi-agent tests share one + # room. Starts with Agent A + User; Agent B joins during the flow. + room_id, _user_id, _user_name = await e2e_room_allocator("agno_multi_agent") agent_a_id, agent_a_name = e2e_agent_info agent_b_id, agent_b_name = e2e_agent_info_2 run_id = uuid.uuid4().hex[:6] @@ -169,7 +171,7 @@ async def test_multi_agent_survives_restart( self, restart_target: str, e2e_config: E2ESettings, - agno_multi_room: tuple[str, str, str], + e2e_room_allocator: RoomAllocator, e2e_agent_info: tuple[str, str], e2e_agent_info_2: tuple[str, str], e2e_session_client: AsyncRestClient, @@ -188,7 +190,9 @@ async def test_multi_agent_survives_restart( once, then have it recompute. B must rehydrate the conversation. - target ``both``: restart A (then continue) and later B. """ - room_id, _user_id, _user_name = agno_multi_room + # Reusing allocator (cached by name): both multi-agent tests share one + # room. Starts with Agent A + User; Agent B joins during the flow. + room_id, _user_id, _user_name = await e2e_room_allocator("agno_multi_agent") agent_a_id, agent_a_name = e2e_agent_info agent_b_id, agent_b_name = e2e_agent_info_2 run_id = uuid.uuid4().hex[:6] diff --git a/tests/e2e/scenarios/agno/test_thoughts.py b/tests/e2e/scenarios/agno/test_thoughts.py index 09922a618..0b42bfc7a 100644 --- a/tests/e2e/scenarios/agno/test_thoughts.py +++ b/tests/e2e/scenarios/agno/test_thoughts.py @@ -24,7 +24,7 @@ import pytest from band_rest import AsyncRestClient -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_room_activity, @@ -50,7 +50,7 @@ class TestAgnoThoughts: async def test_agent_emits_thought_events( self, e2e_config: E2ESettings, - agno_thoughts_room: tuple[str, str, str], + e2e_fresh_room_allocator: RoomAllocator, e2e_agent_info: tuple[str, str], e2e_session_client: AsyncRestClient, ws_client: TrackingWebSocketClient, @@ -61,7 +61,10 @@ async def test_agent_emits_thought_events( Synchronizes on the agent's text reply over WebSocket (the reliable "turn finished" signal), then asserts the thought event via REST. """ - room_id, _user_id, _user_name = agno_thoughts_room + # Fresh room (not the reusing allocator): a reused room's accumulated + # repeats of this question let Claude skip extended thinking, leaving + # reasoning_content empty so no thought is emitted. + room_id, _user_id, _user_name = await e2e_fresh_room_allocator("agno_thoughts") agent_id, agent_name = e2e_agent_info timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) From ef9a65f99a7c811d7472f84ae3b433bccfe35546 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 22 Jun 2026 11:38:56 +0300 Subject: [PATCH 80/90] feat: raise a visible UserWarning for unsupported adapter feature flags When an adapter is configured with emit or capability values it doesn't implement, the base SimpleAdapter now emits a UserWarning in addition to the existing log line, so the misconfiguration surfaces even when logging is not at WARNING level. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/band/core/simple_adapter.py | 39 +++++++++++++--------- tests/core/test_simple_adapter_features.py | 18 +++++++--- tests/integrations/slack/test_wrapping.py | 7 +++- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/band/core/simple_adapter.py b/src/band/core/simple_adapter.py index 966070405..768039d5d 100644 --- a/src/band/core/simple_adapter.py +++ b/src/band/core/simple_adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import warnings from abc import ABC, abstractmethod from typing import Any, ClassVar, Generic, TypeVar, cast @@ -21,6 +22,21 @@ H = TypeVar("H") +def _warn_unsupported( + adapter_name: str, + kind: str, + unsupported: frozenset[Emit] | frozenset[Capability], +) -> None: + """Log and emit a UserWarning naming feature flags an adapter ignores.""" + message = ( + f"{adapter_name} does not support {kind} values: " + f"{', '.join(sorted(v.value for v in unsupported))} " + "(they will have no effect)" + ) + logger.warning(message) + warnings.warn(message, UserWarning, stacklevel=3) + + class SimpleAdapter(Generic[H], ABC): """ Simple base class for framework adapters. @@ -30,7 +46,7 @@ class SimpleAdapter(Generic[H], ABC): Subclasses should declare SUPPORTED_EMIT and SUPPORTED_CAPABILITIES as class-level sets to document what they actually implement. - on_started() will warn if features request unsupported values. + on_started() logs and emits a UserWarning for unsupported values. Example: class MyAdapter(SimpleAdapter[list[ChatMessage]]): @@ -112,21 +128,12 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: self.agent_name = agent_name self.agent_description = agent_description - # Warn on unsupported feature values - unsupported_emit = self.features.emit - self.SUPPORTED_EMIT - if unsupported_emit: - logger.warning( - "%s does not support emit values: %s (they will have no effect)", - type(self).__name__, - ", ".join(sorted(e.value for e in unsupported_emit)), - ) - unsupported_caps = self.features.capabilities - self.SUPPORTED_CAPABILITIES - if unsupported_caps: - logger.warning( - "%s does not support capability values: %s (they will have no effect)", - type(self).__name__, - ", ".join(sorted(c.value for c in unsupported_caps)), - ) + # Warn on unsupported feature values. + name = type(self).__name__ + if unsupported_emit := self.features.emit - self.SUPPORTED_EMIT: + _warn_unsupported(name, "emit", unsupported_emit) + if unsupported_caps := self.features.capabilities - self.SUPPORTED_CAPABILITIES: + _warn_unsupported(name, "capability", unsupported_caps) # Propagate agent name to converter if it supports it if self.history_converter and hasattr(self.history_converter, "set_agent_name"): diff --git a/tests/core/test_simple_adapter_features.py b/tests/core/test_simple_adapter_features.py index c752ab4d7..dc4bec318 100644 --- a/tests/core/test_simple_adapter_features.py +++ b/tests/core/test_simple_adapter_features.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import warnings from typing import Any import pytest @@ -88,7 +89,8 @@ async def test_warns_on_unsupported_emit( features=AdapterFeatures(emit={Emit.EXECUTION, Emit.THOUGHTS}), ) with caplog.at_level(logging.WARNING): - await adapter.on_started("test-agent", "A test agent") + with pytest.warns(UserWarning, match="does not support emit values"): + await adapter.on_started("test-agent", "A test agent") assert "does not support emit values" in caplog.text assert "THOUGHTS" in caplog.text or "thoughts" in caplog.text @@ -102,7 +104,8 @@ async def test_warns_on_unsupported_capabilities( ), ) with caplog.at_level(logging.WARNING): - await adapter.on_started("test-agent", "A test agent") + with pytest.warns(UserWarning, match="does not support capability values"): + await adapter.on_started("test-agent", "A test agent") assert "does not support capability values" in caplog.text assert "CONTACTS" in caplog.text or "contacts" in caplog.text @@ -116,7 +119,9 @@ async def test_no_warning_when_supported( ), ) with caplog.at_level(logging.WARNING): - await adapter.on_started("test-agent", "A test agent") + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + await adapter.on_started("test-agent", "A test agent") assert "does not support" not in caplog.text @pytest.mark.asyncio @@ -125,7 +130,9 @@ async def test_no_warning_on_empty_features( ) -> None: adapter = _TestAdapter() with caplog.at_level(logging.WARNING): - await adapter.on_started("test-agent", "A test agent") + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + await adapter.on_started("test-agent", "A test agent") assert "does not support" not in caplog.text @pytest.mark.asyncio @@ -137,6 +144,7 @@ async def test_bare_adapter_no_warning( features=AdapterFeatures(emit={Emit.EXECUTION}), ) with caplog.at_level(logging.WARNING): - await adapter.on_started("test-agent", "A test agent") + with pytest.warns(UserWarning, match="does not support emit values"): + await adapter.on_started("test-agent", "A test agent") # _BareAdapter has empty SUPPORTED_EMIT, so EXECUTION is unsupported assert "does not support emit values" in caplog.text diff --git a/tests/integrations/slack/test_wrapping.py b/tests/integrations/slack/test_wrapping.py index 0426b0f4e..f826fc35a 100644 --- a/tests/integrations/slack/test_wrapping.py +++ b/tests/integrations/slack/test_wrapping.py @@ -21,6 +21,7 @@ import hmac import json import time +import warnings from datetime import datetime, timezone from types import SimpleNamespace from typing import Any @@ -295,7 +296,11 @@ async def test_on_started_mirrors_inner_support_no_spurious_warning(caplog): adapter, _, _, _ = _make_adapter(inner=inner) with caplog.at_level("WARNING"): - await adapter.on_started("MyBot", "") + with warnings.catch_warnings(): + # A spurious UserWarning here would mean the wrapper failed to + # mirror the inner's support before the base check ran. + warnings.simplefilter("error", UserWarning) + await adapter.on_started("MyBot", "") # Wrapper now reflects the inner's declared support. assert adapter.SUPPORTED_EMIT == frozenset({Emit.EXECUTION}) From c5e06b8ca07ef5e5885126d1c0bb14303844547b Mon Sep 17 00:00:00 2001 From: "thenvoi-argocd[bot]" <227412203+thenvoi-argocd[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:58:40 +0000 Subject: [PATCH 81/90] chore(main): release band-sdk 1.1.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 37fcefaab..5fdd88304 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "1.1.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b31865eda..7b8ec0a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This changelog is automatically generated by [Release Please](https://github.com/googleapis/release-please). +## [1.1.0](https://github.com/thenvoi/thenvoi-sdk-python/compare/band-sdk-v1.0.0...band-sdk-v1.1.0) (2026-06-22) + + +### ⚠ BREAKING CHANGES + +* Memory prompt enum guidance [py] ([#345](https://github.com/thenvoi/thenvoi-sdk-python/issues/345)) + +### Features + +* **sdk:** adapters report boolean activity (working) state to the pl… ([8679757](https://github.com/thenvoi/thenvoi-sdk-python/commit/86797574678b7a2fe59332df087171f9810e5bc9)) +* **sdk:** adapters report boolean activity (working) state to the platform [INT-831] ([#362](https://github.com/thenvoi/thenvoi-sdk-python/issues/362)) ([7ac0e69](https://github.com/thenvoi/thenvoi-sdk-python/commit/7ac0e694ca5b18b6b73322c94b44289674a0b52b)) + + +### Bug Fixes + +* Memory prompt enum guidance [py] ([#345](https://github.com/thenvoi/thenvoi-sdk-python/issues/345)) ([9d1c652](https://github.com/thenvoi/thenvoi-sdk-python/commit/9d1c652a9142dbf94a06416ad36519260c102c6a)) +* require mentions and surface room handles on send failure ([#365](https://github.com/thenvoi/thenvoi-sdk-python/issues/365)) ([cb2d440](https://github.com/thenvoi/thenvoi-sdk-python/commit/cb2d440dfe99789665dd3a1e0a1bd50321e32dfa)) +* **sdk:** cover falsey CrewAI silent completions [INT-489] ([#339](https://github.com/thenvoi/thenvoi-sdk-python/issues/339)) ([f16594c](https://github.com/thenvoi/thenvoi-sdk-python/commit/f16594ce0b0a9b785c7536d8429873f8c3366521)) +* stop Pydantic AI duplicate reply on crash recovery… ([#363](https://github.com/thenvoi/thenvoi-sdk-python/issues/363)) ([5a4eca5](https://github.com/thenvoi/thenvoi-sdk-python/commit/5a4eca5818e9ffb2841a0ca1029517dec5a7c789)) + + +### Documentation + +* move version badge to first position in README badge row ([#361](https://github.com/thenvoi/thenvoi-sdk-python/issues/361)) ([ba10f8b](https://github.com/thenvoi/thenvoi-sdk-python/commit/ba10f8b15d0cb1353cc983bd62f087b5eff6e785)) + + +### Miscellaneous Chores + +* release band-sdk 1.1.0 ([f0d0c7b](https://github.com/thenvoi/thenvoi-sdk-python/commit/f0d0c7b8e0cc5731b1e187ee579cc391487b2327)) + ## [1.0.0](https://github.com/thenvoi/thenvoi-sdk-python/compare/band-sdk-v0.2.11...band-sdk-v1.0.0) (2026-06-11) diff --git a/pyproject.toml b/pyproject.toml index 4f889e97f..1822e30b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "band-sdk" -version = "1.0.0" +version = "1.1.0" description = "A Python SDK for Band API" readme = "README.md" requires-python = ">=3.11" From 5d837dc8dbab388f5e2716d902d86a2ff9fe7242 Mon Sep 17 00:00:00 2001 From: alexander-nikitin-thenvoi Date: Mon, 22 Jun 2026 14:08:57 +0300 Subject: [PATCH 82/90] chore(ci): use shared band-ai app token action Switch release workflow to band-ai/.github/actions/generate-app-token@v1 and remove the promote-dev-to-main workflow that relied on the local GithubToken action. --- .github/workflows/promote-dev-to-main.yml | 128 ---------------------- .github/workflows/release.yml | 11 +- 2 files changed, 5 insertions(+), 134 deletions(-) delete mode 100644 .github/workflows/promote-dev-to-main.yml diff --git a/.github/workflows/promote-dev-to-main.yml b/.github/workflows/promote-dev-to-main.yml deleted file mode 100644 index 214c1e319..000000000 --- a/.github/workflows/promote-dev-to-main.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Promote Dev to Main - -run-name: Promote dev to main by @${{ github.actor }} - -on: - workflow_dispatch: - inputs: - dry_run: - description: 'Dry run (just generate the PR description without creating the PR)' - required: false - default: false - type: boolean - -concurrency: - group: promote-dev-to-main - cancel-in-progress: false - -jobs: - promote: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: dev - - - name: Generate GitHub App Token - id: app_token_generator - uses: ./.github/actions/GithubToken - with: - app_id: ${{ secrets.APP_ID }} - installation_id: ${{ secrets.INSTALLATION_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} - - - name: Fetch main - run: git fetch origin main - - - name: Check if promotion is needed - id: check_promotion - run: | - COMMITS_AHEAD=$(git rev-list --count origin/main..dev) - - if [ "$COMMITS_AHEAD" -eq 0 ]; then - echo "No commits to promote from dev to main" - echo "needs_promotion=false" >> $GITHUB_OUTPUT - exit 0 - fi - - echo "needs_promotion=true" >> $GITHUB_OUTPUT - echo "commits_ahead=$COMMITS_AHEAD" >> $GITHUB_OUTPUT - echo "Found $COMMITS_AHEAD commits to promote from dev to main" - - - name: Generate PR description - id: generate_pr - if: steps.check_promotion.outputs.needs_promotion == 'true' - run: | - { - echo "## Summary" - echo "" - echo "Promotes ${{ steps.check_promotion.outputs.commits_ahead }} commit(s) from \`dev\` to \`main\` (production / release)." - echo "" - echo "## Commits Being Promoted" - echo "" - git log origin/main..dev --pretty=format:"- **%s** (%h) — %an, %ad" --date=short - echo "" - echo "" - echo "## Files Changed" - echo "" - echo '```' - git diff --stat origin/main...dev - echo '```' - echo "" - echo "## Pull Requests Included" - echo "" - PRS=$(git log origin/main..dev --grep="#[0-9]\+" --pretty=format:"%s" | grep -oE "#[0-9]+" | sort -u || true) - if [ -n "$PRS" ]; then - echo "$PRS" | sed 's/^/- /' - else - echo "- No PR references found in commit messages" - fi - echo "" - echo "---" - echo "" - echo "**Promotion Details**" - echo "- Source: \`dev\`" - echo "- Target: \`main\` (production / release)" - echo "- Commits: ${{ steps.check_promotion.outputs.commits_ahead }}" - echo "- Triggered by: @${{ github.actor }}" - echo "- Workflow: [View Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" - echo "" - echo "Merging this PR triggers \`release.yml\` (release-please) on \`main\`." - } > pr_body.md - - echo "Generated PR description:" - cat pr_body.md - - - name: Create Pull Request - if: steps.check_promotion.outputs.needs_promotion == 'true' && inputs.dry_run == false - env: - GH_TOKEN: ${{ steps.app_token_generator.outputs.token }} - run: | - gh pr create \ - --base main \ - --head dev \ - --title "chore: promote dev to main" \ - --body-file pr_body.md \ - --assignee "${{ github.actor }}" - - - name: Dry Run Summary - if: steps.check_promotion.outputs.needs_promotion == 'true' && inputs.dry_run == true - run: | - { - echo "## 🔍 Dry Run — PR was NOT created" - echo "" - echo "The PR would have been created with the following description:" - echo "" - cat pr_body.md - } >> $GITHUB_STEP_SUMMARY - - - name: No promotion needed - if: steps.check_promotion.outputs.needs_promotion == 'false' - run: | - echo "## ✅ main is already up to date with dev. No promotion needed." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0030a9fd..9f0a972f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,13 +28,12 @@ jobs: with: fetch-depth: 0 - - name: Generate GitHub App Token - id: app_token_generator - uses: ./.github/actions/GithubToken + - name: Generate App Token + id: token + uses: band-ai/.github/actions/generate-app-token@v1 with: - app_id: ${{ secrets.APP_ID }} - installation_id: ${{ secrets.INSTALLATION_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + app-client-id: ${{ secrets.APP_CLIENT_ID }} - name: Release Please uses: googleapis/release-please-action@v5 From 5e81dad22a52cbdbab3834f79f930490389c855c Mon Sep 17 00:00:00 2001 From: alexander-nikitin-thenvoi Date: Mon, 22 Jun 2026 14:11:57 +0300 Subject: [PATCH 83/90] Restore promote-dev-to-main.yml --- .github/workflows/promote-dev-to-main.yml | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/promote-dev-to-main.yml diff --git a/.github/workflows/promote-dev-to-main.yml b/.github/workflows/promote-dev-to-main.yml new file mode 100644 index 000000000..214c1e319 --- /dev/null +++ b/.github/workflows/promote-dev-to-main.yml @@ -0,0 +1,128 @@ +name: Promote Dev to Main + +run-name: Promote dev to main by @${{ github.actor }} + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (just generate the PR description without creating the PR)' + required: false + default: false + type: boolean + +concurrency: + group: promote-dev-to-main + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: dev + + - name: Generate GitHub App Token + id: app_token_generator + uses: ./.github/actions/GithubToken + with: + app_id: ${{ secrets.APP_ID }} + installation_id: ${{ secrets.INSTALLATION_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Fetch main + run: git fetch origin main + + - name: Check if promotion is needed + id: check_promotion + run: | + COMMITS_AHEAD=$(git rev-list --count origin/main..dev) + + if [ "$COMMITS_AHEAD" -eq 0 ]; then + echo "No commits to promote from dev to main" + echo "needs_promotion=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "needs_promotion=true" >> $GITHUB_OUTPUT + echo "commits_ahead=$COMMITS_AHEAD" >> $GITHUB_OUTPUT + echo "Found $COMMITS_AHEAD commits to promote from dev to main" + + - name: Generate PR description + id: generate_pr + if: steps.check_promotion.outputs.needs_promotion == 'true' + run: | + { + echo "## Summary" + echo "" + echo "Promotes ${{ steps.check_promotion.outputs.commits_ahead }} commit(s) from \`dev\` to \`main\` (production / release)." + echo "" + echo "## Commits Being Promoted" + echo "" + git log origin/main..dev --pretty=format:"- **%s** (%h) — %an, %ad" --date=short + echo "" + echo "" + echo "## Files Changed" + echo "" + echo '```' + git diff --stat origin/main...dev + echo '```' + echo "" + echo "## Pull Requests Included" + echo "" + PRS=$(git log origin/main..dev --grep="#[0-9]\+" --pretty=format:"%s" | grep -oE "#[0-9]+" | sort -u || true) + if [ -n "$PRS" ]; then + echo "$PRS" | sed 's/^/- /' + else + echo "- No PR references found in commit messages" + fi + echo "" + echo "---" + echo "" + echo "**Promotion Details**" + echo "- Source: \`dev\`" + echo "- Target: \`main\` (production / release)" + echo "- Commits: ${{ steps.check_promotion.outputs.commits_ahead }}" + echo "- Triggered by: @${{ github.actor }}" + echo "- Workflow: [View Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + echo "" + echo "Merging this PR triggers \`release.yml\` (release-please) on \`main\`." + } > pr_body.md + + echo "Generated PR description:" + cat pr_body.md + + - name: Create Pull Request + if: steps.check_promotion.outputs.needs_promotion == 'true' && inputs.dry_run == false + env: + GH_TOKEN: ${{ steps.app_token_generator.outputs.token }} + run: | + gh pr create \ + --base main \ + --head dev \ + --title "chore: promote dev to main" \ + --body-file pr_body.md \ + --assignee "${{ github.actor }}" + + - name: Dry Run Summary + if: steps.check_promotion.outputs.needs_promotion == 'true' && inputs.dry_run == true + run: | + { + echo "## 🔍 Dry Run — PR was NOT created" + echo "" + echo "The PR would have been created with the following description:" + echo "" + cat pr_body.md + } >> $GITHUB_STEP_SUMMARY + + - name: No promotion needed + if: steps.check_promotion.outputs.needs_promotion == 'false' + run: | + echo "## ✅ main is already up to date with dev. No promotion needed." >> $GITHUB_STEP_SUMMARY From cfccbdf2863550ce6f7502fee5004654956a95a9 Mon Sep 17 00:00:00 2001 From: alexander-nikitin-thenvoi Date: Mon, 22 Jun 2026 14:14:26 +0300 Subject: [PATCH 84/90] chore(ci): use shared app token action in promote-dev-to-main --- .github/workflows/promote-dev-to-main.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/promote-dev-to-main.yml b/.github/workflows/promote-dev-to-main.yml index 214c1e319..602c4aa9a 100644 --- a/.github/workflows/promote-dev-to-main.yml +++ b/.github/workflows/promote-dev-to-main.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: dry_run: - description: 'Dry run (just generate the PR description without creating the PR)' + description: "Dry run (just generate the PR description without creating the PR)" required: false default: false type: boolean @@ -29,13 +29,12 @@ jobs: fetch-depth: 0 ref: dev - - name: Generate GitHub App Token - id: app_token_generator - uses: ./.github/actions/GithubToken + - name: Generate App Token + id: token + uses: band-ai/.github/actions/generate-app-token@v1 with: - app_id: ${{ secrets.APP_ID }} - installation_id: ${{ secrets.INSTALLATION_ID }} - private_key: ${{ secrets.APP_PRIVATE_KEY }} + app-private-key: ${{ secrets.APP_PRIVATE_KEY }} + app-client-id: ${{ secrets.APP_CLIENT_ID }} - name: Fetch main run: git fetch origin main From bacbdba064abef3a5a96f35a999d15d7865ff787 Mon Sep 17 00:00:00 2001 From: alexander-nikitin-thenvoi Date: Mon, 22 Jun 2026 14:39:13 +0300 Subject: [PATCH 85/90] ci: use actions/create-github-app-token@v3 directly Drop the band-ai/.github composite action (inaccessible from this public repo) and call actions/create-github-app-token@v3 inline. Also fix the broken steps.app_token_generator.outputs.token reference left over from the earlier rename. Requires secrets.APP_PRIVATE_KEY to be stored as raw PEM. --- .github/workflows/promote-dev-to-main.yml | 12 ++++++------ .github/workflows/release.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/promote-dev-to-main.yml b/.github/workflows/promote-dev-to-main.yml index 602c4aa9a..957e8ed62 100644 --- a/.github/workflows/promote-dev-to-main.yml +++ b/.github/workflows/promote-dev-to-main.yml @@ -29,12 +29,12 @@ jobs: fetch-depth: 0 ref: dev - - name: Generate App Token - id: token - uses: band-ai/.github/actions/generate-app-token@v1 + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v3 with: - app-private-key: ${{ secrets.APP_PRIVATE_KEY }} - app-client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Fetch main run: git fetch origin main @@ -101,7 +101,7 @@ jobs: - name: Create Pull Request if: steps.check_promotion.outputs.needs_promotion == 'true' && inputs.dry_run == false env: - GH_TOKEN: ${{ steps.app_token_generator.outputs.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | gh pr create \ --base main \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3da5b47b..20d33a3e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,18 +28,18 @@ jobs: with: fetch-depth: 0 - - name: Generate App Token - id: token - uses: band-ai/.github/actions/generate-app-token@v1 + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v3 with: - app-private-key: ${{ secrets.APP_PRIVATE_KEY }} - app-client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Release Please uses: googleapis/release-please-action@v5 id: release with: - token: ${{ steps.app_token_generator.outputs.token }} + token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json manifest-file: .release-please-manifest.json target-branch: main From c58370147b4e2464511e4faf943d01310473d099 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 27 Jun 2026 07:47:34 +0300 Subject: [PATCH 86/90] fix(agno): inject Band identity into prompt; split Tom/Jerry example Address PR review feedback: - Inject the Band-registered identity ("You are {name}, {description}.") into the agent prompt at startup via render_system_prompt, matching the other adapters. Previously the fetched agent_name/description were only used for logging and the converter, so the model never saw its Band identity. Lock it in with a test assertion. - Correct the examples README: the adapter does not send a text fallback (that was dropped earlier); the agent must call band_send_message or it stays silent. Reword the Overview and Note accordingly. - Split the combined Tom-and-Jerry example into 03_tom_agent.py and 04_jerry_agent.py (separate processes) to match the other adapters and demonstrate cross-adapter room communication; renumber memory/db examples to 05/06 and point agent_config.yaml.example at the shared tom_agent/jerry_agent entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_config.yaml.example | 18 ++-- .../{03_tom_and_jerry.py => 03_tom_agent.py} | 60 ++++++------ examples/agno/04_jerry_agent.py | 94 +++++++++++++++++++ ...ry_secretary.py => 05_memory_secretary.py} | 2 +- ...no_db_history.py => 06_agno_db_history.py} | 2 +- examples/agno/README.md | 20 ++-- src/band/adapters/agno.py | 22 +++-- tests/adapters/agno/test_adapter.py | 2 + 8 files changed, 157 insertions(+), 63 deletions(-) rename examples/agno/{03_tom_and_jerry.py => 03_tom_agent.py} (50%) create mode 100644 examples/agno/04_jerry_agent.py rename examples/agno/{04_memory_secretary.py => 05_memory_secretary.py} (98%) rename examples/agno/{05_agno_db_history.py => 06_agno_db_history.py} (98%) diff --git a/agent_config.yaml.example b/agent_config.yaml.example index 335ad3d2b..366da36fb 100644 --- a/agent_config.yaml.example +++ b/agent_config.yaml.example @@ -171,20 +171,14 @@ gemini_agent: # Agno Examples # ============================================================================= -# 01_basic_agent.py, 02_tool_reporting.py, 04_memory_secretary.py, -# 05_agno_db_history.py +# 01_basic_agent.py, 02_tool_reporting.py, 05_memory_secretary.py, +# 06_agno_db_history.py agno_agent: agent_id: "" api_key: "" -# 03_tom_and_jerry.py - two character agents in one process -tom: - agent_id: "" - api_key: "" - -jery: - agent_id: "" - api_key: "" +# 03_tom_agent.py - see tom_agent in Shared Agents section +# 04_jerry_agent.py - see jerry_agent in Shared Agents section # Google ADK Examples # ============================================================================= @@ -293,13 +287,13 @@ arena_guesser_3: # ============================================================================= # Tom the cat character agent -# Used by: langgraph/07, claude_sdk/03, anthropic/03, pydantic_ai/03, parlant/04, crewai/05 +# Used by: langgraph/07, claude_sdk/03, anthropic/03, pydantic_ai/03, parlant/04, crewai/05, agno/03 tom_agent: agent_id: "" api_key: "" # Jerry the mouse character agent -# Used by: langgraph/08, claude_sdk/04, anthropic/04, pydantic_ai/04, parlant/05, crewai/06 +# Used by: langgraph/08, claude_sdk/04, anthropic/04, pydantic_ai/04, parlant/05, crewai/06, agno/04 jerry_agent: agent_id: "" api_key: "" diff --git a/examples/agno/03_tom_and_jerry.py b/examples/agno/03_tom_agent.py similarity index 50% rename from examples/agno/03_tom_and_jerry.py rename to examples/agno/03_tom_agent.py index b9923821d..5ed1a1f06 100644 --- a/examples/agno/03_tom_and_jerry.py +++ b/examples/agno/03_tom_agent.py @@ -6,23 +6,29 @@ # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } # /// """ -Tom and Jerry — two Agno character agents in one process. +Tom the cat agent — tries to catch Jerry! -Spins up both Tom (the cat) and Jerry (the mouse) as separate Band agents, -each backed by its own Agno agent with a distinct personality, and runs them -concurrently with asyncio.gather. +This example shows an Agno-backed character agent with a custom personality. +Tom uses the Band toolset to find and invite Jerry, then tries various tactics +to lure Jerry out of his mouse hole. -Add both agents to the same Band room and mention them: they reply in character -and bicker back and forth. Each agent has the Band toolset, so they can also -look up and invite each other, then keep the chase going. +Run Tom and Jerry as two separate processes (each its own Band agent, here +backed by Agno) to show that they communicate through the room regardless of +which adapter backs them — pair this with any other adapter's Jerry and the +conversation works just the same. Start each in its own terminal: + + uv run examples/agno/03_tom_agent.py + uv run examples/agno/04_jerry_agent.py + +The character prompt is loaded from a shared prompts module reused across +adapter implementations. Requires: - - agent_config.yaml with `tom` and `jery` entries (agent_id + api_key) + - agent_config.yaml with a `tom_agent` entry (agent_id + api_key) - BAND_WS_URL and BAND_REST_URL environment variables - ANTHROPIC_API_KEY environment variable (for the Claude model) -Run with (from repo root): - uv run examples/agno/03_tom_and_jerry.py +Note: Must be run from repo root as it imports prompts/characters.py. """ from __future__ import annotations @@ -43,15 +49,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from prompts.characters import generate_jerry_prompt, generate_tom_prompt +from prompts.characters import generate_tom_prompt logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def load_environment() -> tuple[str, str]: - """Load env vars, validate credentials, and return (ws_url, rest_url).""" +async def main() -> None: load_dotenv() if not os.environ.get("ANTHROPIC_API_KEY"): @@ -63,21 +68,17 @@ def load_environment() -> tuple[str, str]: raise ValueError("BAND_WS_URL environment variable is required") if not rest_url: raise ValueError("BAND_REST_URL environment variable is required") - return ws_url, rest_url - -def build_agent( - config_key: str, instructions: str, ws_url: str, rest_url: str -) -> Agent: - """Build a Band agent backed by an in-character Agno agent.""" + # You own the Agno agent — model and in-character instructions. agno_agent = AgnoAgent( model=Claude(id="claude-sonnet-4-6"), - instructions=instructions, + instructions=generate_tom_prompt("Tom"), ) - return Agent.from_config( - config_key, - # emit=EXECUTION posts tool_call/tool_result events so the agents' - # platform actions (lookup, invite, send) are visible in the room. + + agent = Agent.from_config( + "tom_agent", + # emit=EXECUTION posts tool_call/tool_result events so Tom's platform + # actions (lookup, invite, send) are visible in the room. adapter=AgnoAdapter( agno_agent, features=AdapterFeatures(emit={Emit.EXECUTION}) ), @@ -85,15 +86,8 @@ def build_agent( rest_url=rest_url, ) - -async def main() -> None: - ws_url, rest_url = load_environment() - - tom = build_agent("tom", generate_tom_prompt("Tom", "Jerry"), ws_url, rest_url) - jerry = build_agent("jery", generate_jerry_prompt("Jerry", "Tom"), ws_url, rest_url) - - logger.info("Starting Tom and Jerry...") - await asyncio.gather(tom.run(), jerry.run()) + logger.info("Tom is on the prowl, looking for Jerry...") + await agent.run() if __name__ == "__main__": diff --git a/examples/agno/04_jerry_agent.py b/examples/agno/04_jerry_agent.py new file mode 100644 index 000000000..3933e6f29 --- /dev/null +++ b/examples/agno/04_jerry_agent.py @@ -0,0 +1,94 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[agno]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } +# /// +""" +Jerry the mouse agent — outsmarts Tom! + +This example shows an Agno-backed character agent with a custom personality. +Jerry uses the Band toolset to stay one step ahead of Tom, taunting him and +dodging his schemes. + +Run Tom and Jerry as two separate processes (each its own Band agent, here +backed by Agno) to show that they communicate through the room regardless of +which adapter backs them — pair this with any other adapter's Tom and the +conversation works just the same. Start each in its own terminal: + + uv run examples/agno/03_tom_agent.py + uv run examples/agno/04_jerry_agent.py + +The character prompt is loaded from a shared prompts module reused across +adapter implementations. + +Requires: + - agent_config.yaml with a `jerry_agent` entry (agent_id + api_key) + - BAND_WS_URL and BAND_REST_URL environment variables + - ANTHROPIC_API_KEY environment variable (for the Claude model) + +Note: Must be run from repo root as it imports prompts/characters.py. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys + +from agno.agent import Agent as AgnoAgent +from agno.models.anthropic import Claude +from dotenv import load_dotenv + +from band import Agent +from band.adapters import AgnoAdapter +from band.core.types import AdapterFeatures, Emit + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from prompts.characters import generate_jerry_prompt + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main() -> None: + load_dotenv() + + if not os.environ.get("ANTHROPIC_API_KEY"): + raise ValueError("ANTHROPIC_API_KEY environment variable is required") + + ws_url = os.environ.get("BAND_WS_URL") + rest_url = os.environ.get("BAND_REST_URL") + if not ws_url: + raise ValueError("BAND_WS_URL environment variable is required") + if not rest_url: + raise ValueError("BAND_REST_URL environment variable is required") + + # You own the Agno agent — model and in-character instructions. + agno_agent = AgnoAgent( + model=Claude(id="claude-sonnet-4-6"), + instructions=generate_jerry_prompt("Jerry"), + ) + + agent = Agent.from_config( + "jerry_agent", + # emit=EXECUTION posts tool_call/tool_result events so Jerry's platform + # actions (lookup, invite, send) are visible in the room. + adapter=AgnoAdapter( + agno_agent, features=AdapterFeatures(emit={Emit.EXECUTION}) + ), + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Jerry is ready to outsmart Tom...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agno/04_memory_secretary.py b/examples/agno/05_memory_secretary.py similarity index 98% rename from examples/agno/04_memory_secretary.py rename to examples/agno/05_memory_secretary.py index 14a52db30..4c24fb93b 100644 --- a/examples/agno/04_memory_secretary.py +++ b/examples/agno/05_memory_secretary.py @@ -27,7 +27,7 @@ - ANTHROPIC_API_KEY environment variable (for the Claude model) Run with: - uv run examples/agno/04_memory_secretary.py + uv run examples/agno/05_memory_secretary.py """ from __future__ import annotations diff --git a/examples/agno/05_agno_db_history.py b/examples/agno/06_agno_db_history.py similarity index 98% rename from examples/agno/05_agno_db_history.py rename to examples/agno/06_agno_db_history.py index a2d74a1cc..e6976019d 100644 --- a/examples/agno/05_agno_db_history.py +++ b/examples/agno/06_agno_db_history.py @@ -30,7 +30,7 @@ - ANTHROPIC_API_KEY environment variable (for the Claude model) Run with: - uv run examples/agno/05_agno_db_history.py + uv run examples/agno/06_agno_db_history.py """ from __future__ import annotations diff --git a/examples/agno/README.md b/examples/agno/README.md index 0cb902872..520bcb899 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -7,14 +7,17 @@ framework. Agno is model-agnostic: you build and configure your own Agno `Agent` (model, instructions, tools, database, and other Agno settings), then bridge it to Band with -`AgnoAdapter`. The adapter converts Band room history into Agno messages, runs -your agent, and replies with its text output. +`AgnoAdapter`. The adapter converts Band room history into Agno messages and runs +your agent, exposing the Band toolset so the agent can reply. > **Note:** The Band toolset is exposed to the agent — chat and participant > tools always, plus memory/contact tools when the matching capabilities are -> enabled. If the agent doesn't post via `band_send_message`, the adapter sends -> its final text as a fallback, so simple agents reply without extra prompting. -> Tool executions are reported to (and rehydrated from) the room. +> enabled. The agent must call `band_send_message` to say anything in the room; +> if it only returns plain text, nothing is delivered (the adapter logs that it +> stayed silent). Band guidance is injected into the agent's prompt at startup, +> so a capable model will call the tool on its own — but a minimal agent should +> be instructed to use `band_send_message`. Tool executions are reported to (and +> rehydrated from) the room. ## Prerequisites @@ -59,9 +62,10 @@ the same instance elsewhere. |------|-------------| | `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | | `02_tool_reporting.py` | **Tool-execution reporting** - An Agno agent with its own tools; `AdapterFeatures(emit={Emit.EXECUTION})` posts tool_call/tool_result events to the room. | -| `03_tom_and_jerry.py` | **Two agents in one process** - Tom and Jerry, each its own Agno-backed Band agent with a distinct personality, run concurrently with `asyncio.gather`. | -| `04_memory_secretary.py` | **Band memory tools** - Enables `Capability.MEMORY` so an Agno agent can store and recall durable Band memories. | -| `05_agno_db_history.py` | **Agno-owned history** - Uses `db`, `session_id`, and `add_history_to_context=True`; the adapter disables Band history rehydration to avoid duplicate context. | +| `03_tom_agent.py` | **Character agent (Tom)** - An Agno-backed cat agent. Run alongside Jerry — each is its own Band agent process, so they converse through the room even when backed by different adapters. | +| `04_jerry_agent.py` | **Character agent (Jerry)** - The mouse counterpart to Tom; run the two in separate terminals and add both to a room. | +| `05_memory_secretary.py` | **Band memory tools** - Enables `Capability.MEMORY` so an Agno agent can store and recall durable Band memories. | +| `06_agno_db_history.py` | **Agno-owned history** - Uses `db`, `session_id`, and `add_history_to_context=True`; the adapter disables Band history rehydration to avoid duplicate context. | --- diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 9b73d8363..4344155d2 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -20,7 +20,7 @@ PlatformMessage, ) from band.converters.agno import AgnoHistoryConverter, AgnoMessages -from band.runtime.prompts import BASE_INSTRUCTIONS, CONTACT_SECTION, MEMORY_SECTION +from band.runtime.prompts import render_system_prompt from band.runtime.tools import get_band_tool_category try: @@ -561,13 +561,19 @@ def _inject_band_instructions(self) -> None: ) def _band_instructions(self) -> str: - """Compose Band guidance gated on enabled capabilities.""" - parts: list[str] = [BASE_INSTRUCTIONS.strip()] - if Capability.MEMORY in self.features.capabilities: - parts.append(MEMORY_SECTION.strip()) - if Capability.CONTACTS in self.features.capabilities: - parts.append(CONTACT_SECTION.strip()) - return "\n\n".join(parts) + """Compose the Band identity + guidance gated on enabled capabilities. + + Mirrors the other adapters via :func:`render_system_prompt`: prepends + "You are {name}, {description}." so the model knows its Band-registered + identity, then the base instructions and any capability sections. The + developer's own Agno ``instructions`` are preserved separately — this is + appended to ``additional_context`` (see :meth:`_inject_band_instructions`). + """ + return render_system_prompt( + agent_name=self.agent_name or "Agent", + agent_description=self.agent_description or "An AI assistant", + features=self.features, + ) def _build_band_tools( self, tools: AgentToolsProtocol, *, include_contacts: bool diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index d8c317cab..7a4049032 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -267,6 +267,8 @@ async def test_guidance_injected_at_startup_before_any_message( assert isinstance(agent.additional_context, str) assert "## Environment" in agent.additional_context + # Band-registered identity is injected so the model knows who it is. + assert "You are TestBot, desc." in agent.additional_context class TestBandEntrypointBinding: From 140af95ef80dcced3550d3dbf212adcef2da23fc Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sat, 27 Jun 2026 08:01:00 +0300 Subject: [PATCH 87/90] docs(agno): decouple model provider from the agno extra Agno is model-agnostic, but the `agno` optional extra pinned `anthropic`, forcing the Claude SDK onto everyone who installed `band-sdk[agno]` even when using OpenAI/Gemini/Groq. Anthropic was only ever the examples' default. - Drop `anthropic` from the `agno` extra (regen uv.lock); it stays in `dev` so tests/CI are unaffected. - Each example declares its provider (`anthropic`) in its own PEP 723 metadata, so `uv run examples/agno/.py` still pulls it automatically. - examples README: add a Model providers table (import / package / API key for anthropic, openai, google, groq), an Installation section covering both the `uv run` and `uv sync --extra agno` paths, and an OpenAI Quick Start snippet alongside the Claude one. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/agno/01_basic_agent.py | 2 +- examples/agno/02_tool_reporting.py | 2 +- examples/agno/03_tom_agent.py | 2 +- examples/agno/04_jerry_agent.py | 2 +- examples/agno/05_memory_secretary.py | 2 +- examples/agno/06_agno_db_history.py | 2 +- examples/agno/README.md | 71 ++++++++++++++++++++++++++-- pyproject.toml | 1 - uv.lock | 2 - 9 files changed, 73 insertions(+), 13 deletions(-) diff --git a/examples/agno/01_basic_agent.py b/examples/agno/01_basic_agent.py index c56be80ae..1d62c9ff9 100644 --- a/examples/agno/01_basic_agent.py +++ b/examples/agno/01_basic_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/02_tool_reporting.py b/examples/agno/02_tool_reporting.py index a91a2341f..bc398fbca 100644 --- a/examples/agno/02_tool_reporting.py +++ b/examples/agno/02_tool_reporting.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/03_tom_agent.py b/examples/agno/03_tom_agent.py index 5ed1a1f06..f056d34be 100644 --- a/examples/agno/03_tom_agent.py +++ b/examples/agno/03_tom_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/04_jerry_agent.py b/examples/agno/04_jerry_agent.py index 3933e6f29..1d533d364 100644 --- a/examples/agno/04_jerry_agent.py +++ b/examples/agno/04_jerry_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/05_memory_secretary.py b/examples/agno/05_memory_secretary.py index 4c24fb93b..f888b5ec7 100644 --- a/examples/agno/05_memory_secretary.py +++ b/examples/agno/05_memory_secretary.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/06_agno_db_history.py b/examples/agno/06_agno_db_history.py index e6976019d..5a22468d4 100644 --- a/examples/agno/06_agno_db_history.py +++ b/examples/agno/06_agno_db_history.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]"] +# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] # # [tool.uv.sources] # band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } diff --git a/examples/agno/README.md b/examples/agno/README.md index 520bcb899..d54c9edae 100644 --- a/examples/agno/README.md +++ b/examples/agno/README.md @@ -21,16 +21,62 @@ your agent, exposing the Band toolset so the agent can reply. ## Prerequisites -1. **Anthropic API Key** - Set `ANTHROPIC_API_KEY` (or add it to a `.env` file) +1. **A model provider** - Agno is model-agnostic, so you choose the provider and + set its API key (see [Model providers](#model-providers) below). These + examples use Anthropic Claude, so set `ANTHROPIC_API_KEY` (or add it to a + `.env` file). 2. **Band Platform** - Create a remote agent and get credentials, and set `BAND_WS_URL` / `BAND_REST_URL` to the platform those credentials belong to -3. **Dependencies** - Install with `uv sync --extra agno` +3. **Dependencies** - Install the adapter and your provider (see + [Installation](#installation) below). + +--- + +## Model providers + +Agno is model-agnostic: **you** pick the provider when you build the agent, and +`AgnoAdapter` wraps whatever you pass — nothing in the adapter is tied to a +specific provider. Each provider needs two things: its Python package installed +and its API key in the environment. + +| Provider | Import | Package | API key | +|----------|--------|---------|---------| +| Anthropic (examples' default) | `from agno.models.anthropic import Claude` | `anthropic` | `ANTHROPIC_API_KEY` | +| OpenAI | `from agno.models.openai import OpenAIChat` | `openai` | `OPENAI_API_KEY` | +| Google | `from agno.models.google import Gemini` | `google-genai` | `GOOGLE_API_KEY` | +| Groq | `from agno.models.groq import Groq` | `groq` | `GROQ_API_KEY` | + +See [Agno's model docs](https://docs.agno.com/models) for the full list. + +## Installation + +The `agno` extra installs the adapter only — it deliberately does **not** pin a +model provider, so you add the package for the provider you chose above. + +**Run an example directly (recommended):** each script declares `band-sdk[agno]` +*and* its provider (`anthropic`) in its PEP 723 metadata, so `uv` installs +everything into an ephemeral environment automatically — no manual setup: + +```bash +uv run examples/agno/01_basic_agent.py +``` + +**Install into your own project/environment:** + +```bash +# 1. The adapter (provider-free) +uv sync --extra agno # or: uv pip install 'band-sdk[agno]' + +# 2. Your chosen provider's package +uv pip install anthropic # or: openai, google-genai, groq, ... +``` --- ## Quick Start ```python +# Requires: pip install 'band-sdk[agno]' anthropic (ANTHROPIC_API_KEY set) from agno.agent import Agent as AgnoAgent from agno.models.anthropic import Claude @@ -49,6 +95,19 @@ agent = Agent.from_config("agno_agent", adapter=adapter) await agent.run() ``` +Agno is model-agnostic — swap the model and the rest is unchanged. For OpenAI: + +```python +# Requires: pip install 'band-sdk[agno]' openai (OPENAI_API_KEY set) +from agno.models.openai import OpenAIChat + +agno_agent = AgnoAgent( + model=OpenAIChat(id="gpt-4o"), + instructions="You are a helpful assistant. Be concise and friendly.", +) +adapter = AgnoAdapter(agno_agent) +``` + The adapter runs against the agent instance you pass and takes ownership of it: at startup it configures that instance for Band (replaces its `tools` with a per-run factory and appends Band guidance to `additional_context`). Don't reuse @@ -94,9 +153,13 @@ agno_agent: api_key: "your-band-api-key" ``` -Provide your Anthropic API key via environment variable or a `.env` file in the -repository root: +Provide your model provider's API key via environment variable or a `.env` file +in the repository root. The examples use Anthropic Claude: ```bash ANTHROPIC_API_KEY=your-anthropic-api-key ``` + +If you switched the model to another provider (see +[Model providers](#model-providers)), set that provider's key instead — e.g. +`OPENAI_API_KEY`. diff --git a/pyproject.toml b/pyproject.toml index 2117b2270..301c8b6ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,7 +128,6 @@ google_adk = [ ] agno = [ "agno>=2.6.0", - "anthropic>=0.75.0", # Claude model provider for agno ] # dev extra includes ALL framework deps for testing EXCEPT crewai, diff --git a/uv.lock b/uv.lock index c14604525..06d49d756 100644 --- a/uv.lock +++ b/uv.lock @@ -539,7 +539,6 @@ agentcore-runtime = [ ] agno = [ { name = "agno" }, - { name = "anthropic" }, ] anthropic = [ { name = "anthropic" }, @@ -682,7 +681,6 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'bridge-agentcore'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'dev'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'slack'", specifier = ">=3.9,<4" }, - { name = "anthropic", marker = "extra == 'agno'", specifier = ">=0.75.0" }, { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.75.0" }, { name = "anthropic", marker = "extra == 'dev'", specifier = ">=0.75.0" }, { name = "band-client-rest", specifier = "==0.0.10" }, From 91dc3f69023044f60d371c896ff7d2bef636a901 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Sun, 28 Jun 2026 10:22:20 +0300 Subject: [PATCH 88/90] Revert "feat: sdk add agno adapter python int 856" --- AGENTS.md | 4 +- README.md | 4 +- agent_config.yaml.example | 17 +- examples/agno/01_basic_agent.py | 86 -- examples/agno/02_tool_reporting.py | 97 -- examples/agno/03_tom_agent.py | 94 -- examples/agno/04_jerry_agent.py | 94 -- examples/agno/05_memory_secretary.py | 112 --- examples/agno/06_agno_db_history.py | 111 --- examples/agno/README.md | 165 ---- pyproject.toml | 9 - src/band/adapters/__init__.py | 6 - src/band/adapters/agno.py | 721 -------------- src/band/adapters/gemini.py | 10 +- src/band/adapters/google_adk.py | 38 +- src/band/converters/__init__.py | 13 - src/band/converters/agno.py | 124 --- src/band/core/tool_filter.py | 66 +- .../integrations/langgraph/langchain_tools.py | 18 +- src/band/runtime/prompts.py | 7 +- src/band/runtime/tools.py | 20 - tests/adapters/agno/__init__.py | 0 tests/adapters/agno/conftest.py | 145 --- tests/adapters/agno/helpers.py | 201 ---- tests/adapters/agno/test_adapter.py | 891 ------------------ tests/adapters/agno/test_history_guard.py | 141 --- .../adapters/agno/test_history_persistence.py | 119 --- tests/adapters/agno/test_rehydration.py | 254 ----- tests/adapters/test_gemini_adapter.py | 41 - tests/adapters/test_google_adk_adapter.py | 53 ++ tests/converters/test_agno.py | 188 ---- tests/core/test_tool_filter.py | 93 +- tests/e2e/adapters/conftest.py | 31 +- tests/e2e/adapters/test_agno_memory.py | 179 ---- tests/e2e/adapters/test_all_adapters.py | 4 +- tests/e2e/adapters/test_langgraph_memory.py | 73 +- tests/e2e/adapters/test_parlant.py | 2 +- .../test_three_agent_orchestration.py | 2 +- tests/e2e/conftest.py | 442 ++++++++- tests/e2e/fixtures/__init__.py | 7 - tests/e2e/fixtures/clients.py | 158 ---- tests/e2e/fixtures/memory.py | 29 - tests/e2e/fixtures/rooms.py | 270 ------ .../e2e/{helpers/messaging.py => helpers.py} | 208 +--- tests/e2e/helpers/__init__.py | 44 - tests/e2e/helpers/agent.py | 84 -- tests/e2e/helpers/log.py | 49 - tests/e2e/helpers/memory.py | 91 -- tests/e2e/scenarios/agno/README.md | 78 -- tests/e2e/scenarios/agno/__init__.py | 0 tests/e2e/scenarios/agno/conftest.py | 311 ------ .../agno/test_context_persistence.py | 206 ---- .../scenarios/agno/test_database_restart.py | 171 ---- tests/e2e/scenarios/agno/test_multi_agent.py | 345 ------- tests/e2e/scenarios/agno/test_thoughts.py | 108 --- .../e2e/scenarios/test_context_persistence.py | 15 +- .../test_langgraph_restart_rehydration.py | 2 +- tests/e2e/scenarios/test_noisy_busy_room.py | 251 ----- tests/e2e/scenarios/test_room_isolation.py | 42 +- tests/e2e/settings.py | 108 --- tests/framework_configs/adapters.py | 32 - tests/framework_configs/converters.py | 25 - tests/framework_configs/output_adapters.py | 58 -- tests/integrations/test_langgraph_tools.py | 9 +- tests/runtime/test_tools.py | 17 - uv.lock | 266 +----- 66 files changed, 640 insertions(+), 7019 deletions(-) delete mode 100644 examples/agno/01_basic_agent.py delete mode 100644 examples/agno/02_tool_reporting.py delete mode 100644 examples/agno/03_tom_agent.py delete mode 100644 examples/agno/04_jerry_agent.py delete mode 100644 examples/agno/05_memory_secretary.py delete mode 100644 examples/agno/06_agno_db_history.py delete mode 100644 examples/agno/README.md delete mode 100644 src/band/adapters/agno.py delete mode 100644 src/band/converters/agno.py delete mode 100644 tests/adapters/agno/__init__.py delete mode 100644 tests/adapters/agno/conftest.py delete mode 100644 tests/adapters/agno/helpers.py delete mode 100644 tests/adapters/agno/test_adapter.py delete mode 100644 tests/adapters/agno/test_history_guard.py delete mode 100644 tests/adapters/agno/test_history_persistence.py delete mode 100644 tests/adapters/agno/test_rehydration.py delete mode 100644 tests/converters/test_agno.py delete mode 100644 tests/e2e/adapters/test_agno_memory.py delete mode 100644 tests/e2e/fixtures/__init__.py delete mode 100644 tests/e2e/fixtures/clients.py delete mode 100644 tests/e2e/fixtures/memory.py delete mode 100644 tests/e2e/fixtures/rooms.py rename tests/e2e/{helpers/messaging.py => helpers.py} (55%) delete mode 100644 tests/e2e/helpers/__init__.py delete mode 100644 tests/e2e/helpers/agent.py delete mode 100644 tests/e2e/helpers/log.py delete mode 100644 tests/e2e/helpers/memory.py delete mode 100644 tests/e2e/scenarios/agno/README.md delete mode 100644 tests/e2e/scenarios/agno/__init__.py delete mode 100644 tests/e2e/scenarios/agno/conftest.py delete mode 100644 tests/e2e/scenarios/agno/test_context_persistence.py delete mode 100644 tests/e2e/scenarios/agno/test_database_restart.py delete mode 100644 tests/e2e/scenarios/agno/test_multi_agent.py delete mode 100644 tests/e2e/scenarios/agno/test_thoughts.py delete mode 100644 tests/e2e/scenarios/test_noisy_busy_room.py delete mode 100644 tests/e2e/settings.py diff --git a/AGENTS.md b/AGENTS.md index 5d1d8c2dd..6baa6f471 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This is a Python SDK that connects AI agents to the Band collaborative platform. ## Core Features -1. Multi-framework support (LangGraph, Anthropic, CrewAI, Claude SDK, Codex, Pydantic AI, Parlant, Gemini, Letta, Google ADK, OpenCode, Agno) +1. Multi-framework support (LangGraph, Anthropic, CrewAI, Claude SDK, Codex, Pydantic AI, Parlant, Gemini, Letta, Google ADK, OpenCode) 2. A2A protocol support: Bridge to remote A2A agents and expose Band peers as A2A endpoints 3. ACP integration: Editor-facing server and subprocess client adapters (Cursor, Codex, Claude Code) 4. Platform tools for chat, contacts, and memory management @@ -313,7 +313,7 @@ tests/ ├── integration/ # Real API tests (skipped in CI) ├── e2e/ # End-to-end tests (requires live platform + LLM keys) │ ├── adapters/ # Per-adapter smoke & tool execution tests -│ └── scenarios/ # Cross-cutting scenarios (context persistence, room isolation, noisy busy room) +│ └── scenarios/ # Cross-cutting scenarios (context persistence, room isolation) └── conftest.py # Shared fixtures ``` diff --git a/README.md b/README.md index fb9758ab9..7c27de87d 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,6 @@ For the full picture, rooms, contacts, platform tools, and how messages flow - s | Google ADK | `google_adk` | `GoogleADKAdapter` | | [examples](examples/google_adk/) | | Parlant | `parlant` | `ParlantAdapter` | | [examples](examples/parlant/) | | Letta | `letta` | `LettaAdapter` | | [examples](examples/letta/) | -| Agno | `agno` | `AgnoAdapter` | | [examples](examples/agno/) | | Codex | `codex` | `CodexAdapter` | [docs](docs/adapters/codex.md) | [examples](examples/codex/) | | OpenCode | `opencode` | `OpencodeAdapter` | | [examples](examples/opencode/) | @@ -355,7 +354,6 @@ Adapter emit support: | ------- | ----------- | ---------- | ------------- | | Codex | Yes | Yes | Yes | | Claude SDK | Yes | Yes | - | -| Agno | Yes | Yes | - | | OpenCode | Yes | - | Yes | | Letta | Yes | - | Yes | | Anthropic | Yes | - | - | @@ -677,7 +675,7 @@ uv run python examples/run_agent.py --example anthropic uv run python examples/run_agent.py --example codex ``` -`examples/run_agent.py` supports `langgraph`, `pydantic_ai`, `anthropic`, `claude_sdk`, `parlant`, `crewai`, `codex`, `a2a`, and `a2a_gateway`, plus contact-management variants. Other supported adapters have direct example files: `examples/gemini/01_basic_agent.py`, `examples/google_adk/01_basic_agent.py`, `examples/letta/01_basic_agent.py`, `examples/agno/01_basic_agent.py`, and `examples/opencode/01_basic_agent.py`. +`examples/run_agent.py` supports `langgraph`, `pydantic_ai`, `anthropic`, `claude_sdk`, `parlant`, `crewai`, `codex`, `a2a`, and `a2a_gateway`, plus contact-management variants. Other supported adapters have direct example files: `examples/gemini/01_basic_agent.py`, `examples/google_adk/01_basic_agent.py`, `examples/letta/01_basic_agent.py`, and `examples/opencode/01_basic_agent.py`. For a multi-framework collaboration demo that puts CrewAI agents and A2A-bridged services in the same room, see [examples/mixed](examples/mixed/). diff --git a/agent_config.yaml.example b/agent_config.yaml.example index 366da36fb..ca19550a6 100644 --- a/agent_config.yaml.example +++ b/agent_config.yaml.example @@ -167,19 +167,6 @@ gemini_agent: agent_id: "" api_key: "" -# ============================================================================= -# Agno Examples -# ============================================================================= - -# 01_basic_agent.py, 02_tool_reporting.py, 05_memory_secretary.py, -# 06_agno_db_history.py -agno_agent: - agent_id: "" - api_key: "" - -# 03_tom_agent.py - see tom_agent in Shared Agents section -# 04_jerry_agent.py - see jerry_agent in Shared Agents section - # Google ADK Examples # ============================================================================= @@ -287,13 +274,13 @@ arena_guesser_3: # ============================================================================= # Tom the cat character agent -# Used by: langgraph/07, claude_sdk/03, anthropic/03, pydantic_ai/03, parlant/04, crewai/05, agno/03 +# Used by: langgraph/07, claude_sdk/03, anthropic/03, pydantic_ai/03, parlant/04, crewai/05 tom_agent: agent_id: "" api_key: "" # Jerry the mouse character agent -# Used by: langgraph/08, claude_sdk/04, anthropic/04, pydantic_ai/04, parlant/05, crewai/06, agno/04 +# Used by: langgraph/08, claude_sdk/04, anthropic/04, pydantic_ai/04, parlant/05, crewai/06 jerry_agent: agent_id: "" api_key: "" diff --git a/examples/agno/01_basic_agent.py b/examples/agno/01_basic_agent.py deleted file mode 100644 index 1d62c9ff9..000000000 --- a/examples/agno/01_basic_agent.py +++ /dev/null @@ -1,86 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Basic Agno agent example. - -Builds a model-agnostic Agno agent and bridges it to the Band platform via -``AgnoAdapter``. The Agno agent owns the model, instructions, and (later) tools; -the adapter converts Band room history into Agno messages and replies with the -agent's text output. - -Requires: - - agent_config.yaml in the working directory with an `agno_agent` entry - (copy the repo-root agent_config.yaml.example to agent_config.yaml and - fill in the agno_agent credentials) - - BAND_WS_URL and BAND_REST_URL environment variables (the platform the - agent_config.yaml credentials belong to) - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Run with: - uv run examples/agno/01_basic_agent.py -""" - -from __future__ import annotations - -import asyncio -import logging -import os - -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def load_environment() -> tuple[str, str]: - """Load env vars, validate credentials, and return (ws_url, rest_url).""" - load_dotenv() - - if not os.environ.get("ANTHROPIC_API_KEY"): - raise ValueError("ANTHROPIC_API_KEY environment variable is required") - - ws_url = os.environ.get("BAND_WS_URL") - rest_url = os.environ.get("BAND_REST_URL") - if not ws_url: - raise ValueError("BAND_WS_URL environment variable is required") - if not rest_url: - raise ValueError("BAND_REST_URL environment variable is required") - return ws_url, rest_url - - -async def main() -> None: - ws_url, rest_url = load_environment() - - # Build the Agno agent — you choose the model, instructions, and tools. - agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are a helpful assistant. Be concise and friendly.", - ) - - # Bridge the Agno agent to Band. - adapter = AgnoAdapter(agno_agent) - - agent = Agent.from_config( - "agno_agent", - adapter=adapter, - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Starting Agno agent...") - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/02_tool_reporting.py b/examples/agno/02_tool_reporting.py deleted file mode 100644 index bc398fbca..000000000 --- a/examples/agno/02_tool_reporting.py +++ /dev/null @@ -1,97 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Agno agent with tool-execution reporting. - -Builds an Agno agent that has its own tools, and enables Band execution -reporting via ``AdapterFeatures(emit={Emit.EXECUTION})``. Whenever the Agno -agent calls one of its tools, the adapter posts tool_call/tool_result events to -the room so the tool activity is visible in Band. - -Requires: - - agent_config.yaml in the working directory with an `agno_agent` entry - (copy the repo-root agent_config.yaml.example to agent_config.yaml and - fill in the agno_agent credentials) - - BAND_WS_URL and BAND_REST_URL environment variables (the platform the - agent_config.yaml credentials belong to) - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Run with: - uv run examples/agno/02_tool_reporting.py -""" - -from __future__ import annotations - -import asyncio -import logging -import os - -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter -from band.core.types import AdapterFeatures, Emit - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_weather(city: str) -> str: - """Get the current weather for a city.""" - # A real tool would call a weather API; this is a stub for the example. - return f"It is 22°C and sunny in {city}." - - -def load_environment() -> tuple[str, str]: - """Load env vars, validate credentials, and return (ws_url, rest_url).""" - load_dotenv() - - if not os.environ.get("ANTHROPIC_API_KEY"): - raise ValueError("ANTHROPIC_API_KEY environment variable is required") - - ws_url = os.environ.get("BAND_WS_URL") - rest_url = os.environ.get("BAND_REST_URL") - if not ws_url: - raise ValueError("BAND_WS_URL environment variable is required") - if not rest_url: - raise ValueError("BAND_REST_URL environment variable is required") - return ws_url, rest_url - - -async def main() -> None: - ws_url, rest_url = load_environment() - - # The Agno agent owns its tools; the adapter reports their executions. - agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are a helpful assistant. Use tools when relevant.", - tools=[get_weather], - ) - - # emit={Emit.EXECUTION} posts tool_call/tool_result events to the room. - adapter = AgnoAdapter( - agno_agent, - features=AdapterFeatures(emit={Emit.EXECUTION}), - ) - - agent = Agent.from_config( - "agno_agent", - adapter=adapter, - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Starting Agno agent with tool reporting...") - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/03_tom_agent.py b/examples/agno/03_tom_agent.py deleted file mode 100644 index f056d34be..000000000 --- a/examples/agno/03_tom_agent.py +++ /dev/null @@ -1,94 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Tom the cat agent — tries to catch Jerry! - -This example shows an Agno-backed character agent with a custom personality. -Tom uses the Band toolset to find and invite Jerry, then tries various tactics -to lure Jerry out of his mouse hole. - -Run Tom and Jerry as two separate processes (each its own Band agent, here -backed by Agno) to show that they communicate through the room regardless of -which adapter backs them — pair this with any other adapter's Jerry and the -conversation works just the same. Start each in its own terminal: - - uv run examples/agno/03_tom_agent.py - uv run examples/agno/04_jerry_agent.py - -The character prompt is loaded from a shared prompts module reused across -adapter implementations. - -Requires: - - agent_config.yaml with a `tom_agent` entry (agent_id + api_key) - - BAND_WS_URL and BAND_REST_URL environment variables - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Note: Must be run from repo root as it imports prompts/characters.py. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import sys - -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter -from band.core.types import AdapterFeatures, Emit - - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -from prompts.characters import generate_tom_prompt - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def main() -> None: - load_dotenv() - - if not os.environ.get("ANTHROPIC_API_KEY"): - raise ValueError("ANTHROPIC_API_KEY environment variable is required") - - ws_url = os.environ.get("BAND_WS_URL") - rest_url = os.environ.get("BAND_REST_URL") - if not ws_url: - raise ValueError("BAND_WS_URL environment variable is required") - if not rest_url: - raise ValueError("BAND_REST_URL environment variable is required") - - # You own the Agno agent — model and in-character instructions. - agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions=generate_tom_prompt("Tom"), - ) - - agent = Agent.from_config( - "tom_agent", - # emit=EXECUTION posts tool_call/tool_result events so Tom's platform - # actions (lookup, invite, send) are visible in the room. - adapter=AgnoAdapter( - agno_agent, features=AdapterFeatures(emit={Emit.EXECUTION}) - ), - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Tom is on the prowl, looking for Jerry...") - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/04_jerry_agent.py b/examples/agno/04_jerry_agent.py deleted file mode 100644 index 1d533d364..000000000 --- a/examples/agno/04_jerry_agent.py +++ /dev/null @@ -1,94 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Jerry the mouse agent — outsmarts Tom! - -This example shows an Agno-backed character agent with a custom personality. -Jerry uses the Band toolset to stay one step ahead of Tom, taunting him and -dodging his schemes. - -Run Tom and Jerry as two separate processes (each its own Band agent, here -backed by Agno) to show that they communicate through the room regardless of -which adapter backs them — pair this with any other adapter's Tom and the -conversation works just the same. Start each in its own terminal: - - uv run examples/agno/03_tom_agent.py - uv run examples/agno/04_jerry_agent.py - -The character prompt is loaded from a shared prompts module reused across -adapter implementations. - -Requires: - - agent_config.yaml with a `jerry_agent` entry (agent_id + api_key) - - BAND_WS_URL and BAND_REST_URL environment variables - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Note: Must be run from repo root as it imports prompts/characters.py. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import sys - -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter -from band.core.types import AdapterFeatures, Emit - - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -from prompts.characters import generate_jerry_prompt - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def main() -> None: - load_dotenv() - - if not os.environ.get("ANTHROPIC_API_KEY"): - raise ValueError("ANTHROPIC_API_KEY environment variable is required") - - ws_url = os.environ.get("BAND_WS_URL") - rest_url = os.environ.get("BAND_REST_URL") - if not ws_url: - raise ValueError("BAND_WS_URL environment variable is required") - if not rest_url: - raise ValueError("BAND_REST_URL environment variable is required") - - # You own the Agno agent — model and in-character instructions. - agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions=generate_jerry_prompt("Jerry"), - ) - - agent = Agent.from_config( - "jerry_agent", - # emit=EXECUTION posts tool_call/tool_result events so Jerry's platform - # actions (lookup, invite, send) are visible in the room. - adapter=AgnoAdapter( - agno_agent, features=AdapterFeatures(emit={Emit.EXECUTION}) - ), - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Jerry is ready to outsmart Tom...") - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/05_memory_secretary.py b/examples/agno/05_memory_secretary.py deleted file mode 100644 index f888b5ec7..000000000 --- a/examples/agno/05_memory_secretary.py +++ /dev/null @@ -1,112 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Agno agent with Band memory tools enabled. - -This example gives an Agno "secretary" agent access to Band memory tools via -``Capability.MEMORY``. The agent can store durable preferences, profile facts, -standing instructions, and reusable project context, then recall them in later -conversations. - -Try prompts like: -- "Remember that I prefer concise status updates." -- "Remember this for the whole organization: our Q3 launch codename is Cedar." -- "What do you remember about my update style?" - -Requires: - - agent_config.yaml in the working directory with an `agno_agent` entry - (copy the repo-root agent_config.yaml.example to agent_config.yaml and - fill in the agno_agent credentials) - - BAND_WS_URL and BAND_REST_URL environment variables (the platform the - agent_config.yaml credentials belong to) - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Run with: - uv run examples/agno/05_memory_secretary.py -""" - -from __future__ import annotations - -import asyncio -import logging -import os - -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter -from band.core.types import AdapterFeatures, Capability, Emit - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -SECRETARY_INSTRUCTIONS = ( - "You are a personal secretary who helps the user preserve useful long-term " - "context. Actively look for durable information worth remembering: user " - "preferences, profile details, standing instructions, important project " - "facts, and reusable workflows. When the user shares something durable, use " - "Band memory tools to store it before replying. Use memory sparingly: do not " - "store one-off requests, temporary chat context, or sensitive information " - "unless the user clearly asks you to remember it. When asked what you " - "remember, use Band memory tools to search before answering. Keep responses " - "short." -) - - -def get_required_env(name: str) -> str: - """Return a required environment variable or raise a clear error.""" - value = os.environ.get(name) - if not value: - raise ValueError(f"{name} environment variable is required") - return value - - -def load_environment() -> tuple[str, str]: - """Load env vars, validate credentials, and return (ws_url, rest_url).""" - load_dotenv() - - get_required_env("ANTHROPIC_API_KEY") - ws_url = get_required_env("BAND_WS_URL") - rest_url = get_required_env("BAND_REST_URL") - return ws_url, rest_url - - -async def main() -> None: - ws_url, rest_url = load_environment() - - agno_agent = AgnoAgent( - model=Claude(id=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6")), - instructions=SECRETARY_INSTRUCTIONS, - ) - - adapter = AgnoAdapter( - agno_agent, - features=AdapterFeatures( - capabilities={Capability.MEMORY}, - # Useful while learning: memory tool calls are visible as room events. - emit={Emit.EXECUTION}, - ), - ) - - agent = Agent.from_config( - "agno_agent", - adapter=adapter, - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Starting Agno memory secretary...") - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/06_agno_db_history.py b/examples/agno/06_agno_db_history.py deleted file mode 100644 index 5a22468d4..000000000 --- a/examples/agno/06_agno_db_history.py +++ /dev/null @@ -1,111 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = ["band-sdk[agno]", "anthropic>=0.75.0"] -# -# [tool.uv.sources] -# band-sdk = { git = "https://github.com/thenvoi/thenvoi-sdk-python.git" } -# /// -""" -Agno-owned conversation history with a database. - -This example configures Agno to persist and replay prior turns itself by using -``db=...``, ``session_id=...``, and ``add_history_to_context=True``. When -``AgnoAdapter`` detects this mode, it disables Band history rehydration for the -model input so the same prior turns are not injected twice. - -The example uses Agno's in-memory database so it is easy to run. It preserves -history only while this process is alive. For production, replace ``InMemoryDb`` -with a persistent Agno database and keep the same session-id strategy. - -Try prompts like: -- "Remember that the release checklist lives in Notion page R-42." -- "What checklist page did I mention?" - -Requires: - - agent_config.yaml in the working directory with an `agno_agent` entry - (copy the repo-root agent_config.yaml.example to agent_config.yaml and - fill in the agno_agent credentials) - - BAND_WS_URL and BAND_REST_URL environment variables (the platform the - agent_config.yaml credentials belong to) - - ANTHROPIC_API_KEY environment variable (for the Claude model) - -Run with: - uv run examples/agno/06_agno_db_history.py -""" - -from __future__ import annotations - -import asyncio -import logging -import os - -from agno.agent import Agent as AgnoAgent -from agno.db.in_memory import InMemoryDb -from agno.models.anthropic import Claude -from dotenv import load_dotenv - -from band import Agent -from band.adapters import AgnoAdapter - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def get_required_env(name: str) -> str: - """Return a required environment variable or raise a clear error.""" - value = os.environ.get(name) - if not value: - raise ValueError(f"{name} environment variable is required") - return value - - -def load_environment() -> tuple[str, str]: - """Load env vars, validate credentials, and return (ws_url, rest_url).""" - load_dotenv() - - get_required_env("ANTHROPIC_API_KEY") - ws_url = get_required_env("BAND_WS_URL") - rest_url = get_required_env("BAND_REST_URL") - return ws_url, rest_url - - -async def main() -> None: - ws_url, rest_url = load_environment() - - db = InMemoryDb() - session_id = os.environ.get("AGNO_SESSION_ID", "band-agno-db-history") - - agno_agent = AgnoAgent( - model=Claude(id=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6")), - db=db, - session_id=session_id, - add_history_to_context=True, - instructions=( - "You are a helpful assistant with Agno-managed conversation history. " - "When acknowledging or recalling a value the user asked you to " - "remember, include the exact value in your reply. Keep responses " - "short." - ), - ) - - adapter = AgnoAdapter( - agno_agent, - # AgnoAdapter passes session_id on each run. This keeps the example tied - # to the Agno session configured above instead of defaulting to room_id. - session_id_factory=lambda _room_id: session_id, - ) - - agent = Agent.from_config( - "agno_agent", - adapter=adapter, - ws_url=ws_url, - rest_url=rest_url, - ) - - logger.info("Starting Agno DB-history agent (session_id=%s)...", session_id) - await agent.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/agno/README.md b/examples/agno/README.md deleted file mode 100644 index d54c9edae..000000000 --- a/examples/agno/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# Agno Examples for Band - -Examples for building Band agents with the [Agno](https://docs.agno.com) -framework. - -## Overview - -Agno is model-agnostic: you build and configure your own Agno `Agent` (model, -instructions, tools, database, and other Agno settings), then bridge it to Band with -`AgnoAdapter`. The adapter converts Band room history into Agno messages and runs -your agent, exposing the Band toolset so the agent can reply. - -> **Note:** The Band toolset is exposed to the agent — chat and participant -> tools always, plus memory/contact tools when the matching capabilities are -> enabled. The agent must call `band_send_message` to say anything in the room; -> if it only returns plain text, nothing is delivered (the adapter logs that it -> stayed silent). Band guidance is injected into the agent's prompt at startup, -> so a capable model will call the tool on its own — but a minimal agent should -> be instructed to use `band_send_message`. Tool executions are reported to (and -> rehydrated from) the room. - -## Prerequisites - -1. **A model provider** - Agno is model-agnostic, so you choose the provider and - set its API key (see [Model providers](#model-providers) below). These - examples use Anthropic Claude, so set `ANTHROPIC_API_KEY` (or add it to a - `.env` file). -2. **Band Platform** - Create a remote agent and get credentials, and set - `BAND_WS_URL` / `BAND_REST_URL` to the platform those credentials belong to -3. **Dependencies** - Install the adapter and your provider (see - [Installation](#installation) below). - ---- - -## Model providers - -Agno is model-agnostic: **you** pick the provider when you build the agent, and -`AgnoAdapter` wraps whatever you pass — nothing in the adapter is tied to a -specific provider. Each provider needs two things: its Python package installed -and its API key in the environment. - -| Provider | Import | Package | API key | -|----------|--------|---------|---------| -| Anthropic (examples' default) | `from agno.models.anthropic import Claude` | `anthropic` | `ANTHROPIC_API_KEY` | -| OpenAI | `from agno.models.openai import OpenAIChat` | `openai` | `OPENAI_API_KEY` | -| Google | `from agno.models.google import Gemini` | `google-genai` | `GOOGLE_API_KEY` | -| Groq | `from agno.models.groq import Groq` | `groq` | `GROQ_API_KEY` | - -See [Agno's model docs](https://docs.agno.com/models) for the full list. - -## Installation - -The `agno` extra installs the adapter only — it deliberately does **not** pin a -model provider, so you add the package for the provider you chose above. - -**Run an example directly (recommended):** each script declares `band-sdk[agno]` -*and* its provider (`anthropic`) in its PEP 723 metadata, so `uv` installs -everything into an ephemeral environment automatically — no manual setup: - -```bash -uv run examples/agno/01_basic_agent.py -``` - -**Install into your own project/environment:** - -```bash -# 1. The adapter (provider-free) -uv sync --extra agno # or: uv pip install 'band-sdk[agno]' - -# 2. Your chosen provider's package -uv pip install anthropic # or: openai, google-genai, groq, ... -``` - ---- - -## Quick Start - -```python -# Requires: pip install 'band-sdk[agno]' anthropic (ANTHROPIC_API_KEY set) -from agno.agent import Agent as AgnoAgent -from agno.models.anthropic import Claude - -from band import Agent -from band.adapters import AgnoAdapter - -# You own the Agno agent — model, instructions, tools. -agno_agent = AgnoAgent( - model=Claude(id="claude-sonnet-4-6"), - instructions="You are a helpful assistant. Be concise and friendly.", -) - -# Bridge it to Band. -adapter = AgnoAdapter(agno_agent) -agent = Agent.from_config("agno_agent", adapter=adapter) -await agent.run() -``` - -Agno is model-agnostic — swap the model and the rest is unchanged. For OpenAI: - -```python -# Requires: pip install 'band-sdk[agno]' openai (OPENAI_API_KEY set) -from agno.models.openai import OpenAIChat - -agno_agent = AgnoAgent( - model=OpenAIChat(id="gpt-4o"), - instructions="You are a helpful assistant. Be concise and friendly.", -) -adapter = AgnoAdapter(agno_agent) -``` - -The adapter runs against the agent instance you pass and takes ownership of it: -at startup it configures that instance for Band (replaces its `tools` with a -per-run factory and appends Band guidance to `additional_context`). Don't reuse -the same instance elsewhere. - ---- - -## Examples - -| File | Description | -|------|-------------| -| `01_basic_agent.py` | **Minimal setup** - A Claude-backed Agno agent bridged to Band via `AgnoAdapter`. | -| `02_tool_reporting.py` | **Tool-execution reporting** - An Agno agent with its own tools; `AdapterFeatures(emit={Emit.EXECUTION})` posts tool_call/tool_result events to the room. | -| `03_tom_agent.py` | **Character agent (Tom)** - An Agno-backed cat agent. Run alongside Jerry — each is its own Band agent process, so they converse through the room even when backed by different adapters. | -| `04_jerry_agent.py` | **Character agent (Jerry)** - The mouse counterpart to Tom; run the two in separate terminals and add both to a room. | -| `05_memory_secretary.py` | **Band memory tools** - Enables `Capability.MEMORY` so an Agno agent can store and recall durable Band memories. | -| `06_agno_db_history.py` | **Agno-owned history** - Uses `db`, `session_id`, and `add_history_to_context=True`; the adapter disables Band history rehydration to avoid duplicate context. | - ---- - -## Running Examples - -```bash -# From repository root -cp agent_config.yaml.example agent_config.yaml -# edit the agno_agent entry in agent_config.yaml with your Band agent_id + api_key - -uv run examples/agno/01_basic_agent.py -``` - -`Agent.from_config` looks for `agent_config.yaml` in the current working -directory, so run from the directory that contains it. - ---- - -## Configuration - -Add your agent credentials to `agent_config.yaml`: - -```yaml -agno_agent: - agent_id: "your-agent-id" - api_key: "your-band-api-key" -``` - -Provide your model provider's API key via environment variable or a `.env` file -in the repository root. The examples use Anthropic Claude: - -```bash -ANTHROPIC_API_KEY=your-anthropic-api-key -``` - -If you switched the model to another provider (see -[Model providers](#model-providers)), set that provider's key instead — e.g. -`OPENAI_API_KEY`. diff --git a/pyproject.toml b/pyproject.toml index 9b8dea06e..1822e30b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,9 +126,6 @@ agentcore_runtime = [ google_adk = [ "google-adk>=1.0.0,<2", ] -agno = [ - "agno>=2.6.0", -] # dev extra includes ALL framework deps for testing EXCEPT crewai, # which conflicts with both parlant and pydantic-ai (see tool.uv.conflicts). @@ -178,15 +175,11 @@ dev = [ "google-genai>=1.43.0", # Include google-adk for testing "google-adk>=1.0.0,<2", - # Include Agno for testing - "agno>=2.6.0", # Include bridge deps for testing "aiohttp>=3.9,<4", "python-dotenv>=1.2.2", # Include bridge_agentcore deps for testing "boto3>=1.35.0", - # Pretty E2E test output - "rich>=13.0.0", # Development tools "pre-commit>=3.0.0", "ruff>=0.8.0", @@ -212,8 +205,6 @@ dev-crewai = [ "openai>=2.0.0", "nest-asyncio>=1.6.0", "pillow>=12.1.1", - # Pretty E2E test output - "rich>=13.0.0", # Development tools "ruff>=0.8.0", "pyrefly>=0.18.0", diff --git a/src/band/adapters/__init__.py b/src/band/adapters/__init__.py index 7e3823b5f..204c0a713 100644 --- a/src/band/adapters/__init__.py +++ b/src/band/adapters/__init__.py @@ -42,7 +42,6 @@ ACPServer as ACPServer, BandACPServerAdapter as BandACPServerAdapter, ) - from band.adapters.agno import AgnoAdapter as AgnoAdapter from band.adapters.gemini import GeminiAdapter as GeminiAdapter from band.adapters.google_adk import GoogleADKAdapter as GoogleADKAdapter from band.adapters.opencode import OpencodeAdapter as OpencodeAdapter @@ -68,7 +67,6 @@ "ACPClientAdapter", "ACPServer", "BandACPServerAdapter", - "AgnoAdapter", "GeminiAdapter", "GoogleADKAdapter", "OpencodeAdapter", @@ -143,10 +141,6 @@ def __getattr__(name: str) -> type: elif name == "ACPServer": return ACPServer return BandACPServerAdapter - elif name == "AgnoAdapter": - from band.adapters.agno import AgnoAdapter - - return AgnoAdapter elif name == "GeminiAdapter": from band.adapters.gemini import GeminiAdapter diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py deleted file mode 100644 index 4344155d2..000000000 --- a/src/band/adapters/agno.py +++ /dev/null @@ -1,721 +0,0 @@ -"""Agno adapter using the SimpleAdapter pattern.""" - -from __future__ import annotations - -import json -import logging -import warnings -from collections.abc import Awaitable, Callable, Iterator -from contextlib import contextmanager -from contextvars import ContextVar -from typing import TYPE_CHECKING, Any, ClassVar - -from band.core.protocols import AgentToolsProtocol -from band.core.simple_adapter import SimpleAdapter -from band.core.tool_filter import filter_tool_schemas -from band.core.types import ( - AdapterFeatures, - Capability, - Emit, - PlatformMessage, -) -from band.converters.agno import AgnoHistoryConverter, AgnoMessages -from band.runtime.prompts import render_system_prompt -from band.runtime.tools import get_band_tool_category - -try: - from agno.models.message import Message - from agno.run.agent import ( - RunOutput, - ToolCallCompletedEvent, - ToolCallStartedEvent, - ) - from agno.tools import Toolkit - from agno.tools.function import Function - from agno.utils.callables import ainvoke_callable_factory, is_callable_factory -except ImportError as e: - raise ImportError( - "agno is required for the Agno adapter.\n" - "Install with: pip install 'band-sdk[agno]'" - ) from e - -if TYPE_CHECKING: - from agno.agent import Agent as AgnoAgent - from agno.run.agent import RunOutputEvent - -logger = logging.getLogger(__name__) - -# These tools already produce visible room output. -_SELF_REPORTING_TOOLS = frozenset({"band_send_message", "band_send_event"}) - -# Conversation roles to persist across turns. Allowlisting these drops Agno's -# per-run injected messages (system/developer instructions, datetime/state -# context, summaries) so they are not replayed alongside freshly injected ones. -_CONVERSATION_ROLES = frozenset({"user", "assistant", "tool"}) - -# Current room tools for wired Agno tool entrypoints. -_current_tools: ContextVar[AgentToolsProtocol | None] = ContextVar( - "agno_current_tools", default=None -) - - -def _tool_executions(response: RunOutput) -> list[Any]: - return list(getattr(response, "tools", None) or []) - - -def _tool_name(execution: Any) -> str: - return getattr(execution, "tool_name", None) or "" - - -def _make_band_entrypoint(tool_name: str) -> Callable[..., Awaitable[str]]: - async def _entrypoint(**kwargs: Any) -> str: - active = _current_tools.get() - if active is None: - return f"Error: no active Band context for tool {tool_name}" - result = await active.execute_tool_call(tool_name, kwargs) - return result if isinstance(result, str) else json.dumps(result, default=str) - - _entrypoint.__name__ = tool_name - return _entrypoint - - -@contextmanager -def _bind_room_tools(tools: AgentToolsProtocol) -> Iterator[None]: - """Bind room tools for one Agno run.""" - token = _current_tools.set(tools) - try: - yield - finally: - _current_tools.reset(token) - - -class AgnoAdapter(SimpleAdapter[AgnoMessages]): - """Bridge a user-built Agno agent to Band. - - "User" throughout this adapter means the SDK integrator who built and - configured the Agno agent — never a chat end-user (``sender_type`` "User"). - - Note on replies: like the other adapters, this one delivers nothing on its - own — the agent must call ``band_send_message`` to communicate. The base - prompt states "plain text output is not delivered"; an agent that only - returns plain text stays silent. It is up to the agent (the LLM) to decide - whether to respond and whom to address. - - Note on ``Emit.THOUGHTS``: when enabled, the agent's **raw** - ``reasoning_content`` is posted to the room as a thought event. This can - surface chain-of-thought and intermediate context, so it is strictly - opt-in — enable it only when that visibility is intended. - """ - - SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset( - {Emit.EXECUTION, Emit.THOUGHTS} - ) - SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( - {Capability.MEMORY, Capability.CONTACTS} - ) - - def __init__( - self, - agent: AgnoAgent, - *, - history_converter: AgnoHistoryConverter | None = None, - features: AdapterFeatures | None = None, - session_id_factory: Callable[[str], str] = lambda room_id: room_id, - ) -> None: - """Bridge a user-built Agno agent to Band. - - The adapter runs against the ``agent`` instance you pass **directly**. - At startup it configures that instance for Band -- replacing its - ``tools`` with a per-run factory, disabling ``cache_callables``, and - appending Band guidance to ``additional_context``. The adapter therefore - takes ownership of the agent; do not reuse the same instance elsewhere. - - Args: - agent: A fully configured Agno agent to bridge to Band. - session_id_factory: Maps a Band ``room_id`` to the Agno - ``session_id`` used for that room's runs. Defaults to using the - ``room_id`` itself, so each Band room is an isolated Agno - session. This **overrides** any ``session_id`` configured on - the agent. Consequence: Agno DB history previously stored under - the agent's original ``session_id`` is no longer reused (runs - are keyed by ``room_id``). To keep a single shared session - across rooms, pass e.g. ``session_id_factory=lambda _r: "fixed"``. - """ - super().__init__( - history_converter=history_converter or AgnoHistoryConverter(), - features=features, - ) - - # The caller's agent is used directly. It becomes the runtime agent - # (self._agent) in on_started, where the agent-dependent Band - # configuration is applied -- deferring that work out of __init__. - self._given_agent = agent - self._agent: AgnoAgent | None = None - self._session_id_factory = session_id_factory - - # Running per-room transcripts; bootstrap history seeds each room. - self._message_history: dict[str, list[Message]] = {} - # Band tools are exposed per-run via a callable-tools factory installed on - # the shared agent (see _resolve_room_tools), so each room's run offers - # exactly its own tool set -- no cross-room schema leakage. The user's own - # tools (those they configured on the agent, captured at startup) are - # re-included on every run, and may be either a static list or a per-run - # callable factory. - self._user_tools: list[Any] | Callable[..., Any] = [] - # Built Functions cached by their only dynamic input (include_contacts), - # so the schema build runs at most twice for the process lifetime rather - # than on every run. Entrypoints route through the _current_tools - # ContextVar, so the cached list is safe to reuse across rooms. - self._band_tools_cache: dict[bool, list[Function]] = {} - - # Resolved against the runtime agent in on_started, once it exists. - self._agno_manages_history = False - - @property - def agent(self) -> AgnoAgent | None: - """The Agno agent this adapter runs against, set in on_started.""" - return self._agent - - def _detect_agno_history(self, agent: AgnoAgent) -> bool: - """Detect whether Agno persists and replays its own history. - - Agno loads prior runs into context only when ``add_history_to_context`` - is set *and* a database is attached (without a ``db`` the feature is - inert). When it does, Band must not also rehydrate platform history into - the run input, or the two history sources collide and contaminate the - model context. Band still keeps its own per-turn transcript store; it - simply stops feeding it back into the run. - """ - manages = bool( - getattr(agent, "add_history_to_context", False) - and getattr(agent, "db", None) is not None - ) - if manages: - warnings.warn( - "This Agno agent manages its own conversation history " - "(add_history_to_context=True with a database). Band's history " - "rehydration is disabled to avoid contaminating the context; " - "Agno will replay prior turns from its database via session " - "persistence.", - UserWarning, - stacklevel=3, - ) - return manages - - def _warn_on_memory_collision(self, agent: AgnoAgent) -> None: - """Warn when Band and Agno memory are both enabled.""" - if Capability.MEMORY not in self.features.capabilities: - return - - enabled: list[str] = [] - if agent.update_memory_on_run: - enabled.append("update_memory_on_run") - if agent.enable_agentic_memory: - enabled.append("enable_agentic_memory") - - if enabled: - warnings.warn( - "Capability.MEMORY exposes Band memory tools to the agent, but " - f"this Agno agent also manages its own memory ({', '.join(enabled)}). " - "The two memory systems collide; disable one of them.", - UserWarning, - stacklevel=3, - ) - - async def on_started(self, agent_name: str, agent_description: str) -> None: - """Configure the caller's agent for Band and sync the converter identity. - - The adapter runs against the agent passed at construction. Agent-dependent - checks and the Band configuration (tool factory, ``additional_context``) - run here, not in ``__init__``, so they happen once at startup. - """ - await super().on_started(agent_name, agent_description) - - agent = self._given_agent - self._agent = agent - self._agno_manages_history = self._detect_agno_history(agent) - self._warn_on_memory_collision(agent) - - # Install per-run tool resolution: capture the user's own tools, then - # replace ``agent.tools`` with our factory so each run offers exactly the - # active room's tool set (see _resolve_room_tools). Disable Agno's - # callable-tools cache so the factory runs every turn regardless of - # session_id; we cache the built Functions ourselves in _band_tools_cache. - self._capture_user_tools(agent) - agent.cache_callables = False - # Agno's `tools` type annotation lists only sync factories, but its - # resolver (ainvoke_callable_factory) explicitly supports async ones, and - # the adapter only ever runs via async `arun`. - agent.tools = self._resolve_room_tools # type: ignore[assignment] - - # Band guidance is composed purely from static capabilities, so inject it - # once here -- before any room runs -- rather than lazily on first message. - self._inject_band_instructions() - - # Converter identity (used to map this agent's own past messages to the - # assistant role) is synced by SimpleAdapter.on_started above, which - # calls set_agent_name on any converter that defines it. - - logger.info("Agno adapter started for agent: %s", agent_name) - logger.debug( - "Agno adapter features: emit=%s capabilities=%s", - sorted(e.value for e in self.features.emit), - sorted(c.value for c in self.features.capabilities), - ) - - async def on_message( - self, - msg: PlatformMessage, - tools: AgentToolsProtocol, - history: AgnoMessages, - participants_msg: str | None, - contacts_msg: str | None, - *, - is_session_bootstrap: bool, - room_id: str, - ) -> None: - """Run the user's Agno agent and ensure a reply is sent.""" - logger.info( - "Room %s msg %s: handling from %s (sender=%s, bootstrap=%s)", - room_id, - msg.id, - msg.sender_name or msg.sender_type, - msg.sender_id, - is_session_bootstrap, - ) - - messages = self._build_run_input( - msg, - history, - participants_msg, - contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) - response = await self._run_agent( - messages, tools, room_id=room_id, msg_id=msg.id - ) - if response is None: - return - - if Emit.THOUGHTS in self.features.emit: - await self._report_thoughts(response, tools, room_id=room_id, msg_id=msg.id) - - self._persist_turn(room_id, response) - - if not any( - _tool_name(execution) == "band_send_message" - for execution in _tool_executions(response) - ): - logger.debug( - "Room %s msg %s: agent did not call band_send_message; " - "nothing delivered", - room_id, - msg.id, - ) - - async def on_cleanup(self, room_id: str) -> None: - """Drop the room's accumulated transcript when the agent leaves.""" - self._message_history.pop(room_id, None) - - def _prior_transcript( - self, - history: AgnoMessages, - *, - is_session_bootstrap: bool, - room_id: str, - ) -> list[Message]: - """Committed prior-turn messages that seed this run, as a fresh list. - - ``_message_history[room_id]`` is the *committed* record of prior turns. - It is written only at commit points — here (seeding rehydrated platform - history on bootstrap) and in :meth:`_persist_turn` (after a successful - run). A *copy* is returned so the caller composes this turn's input - without mutating the committed transcript; otherwise a failed or - message-less run would leave the injected system/user messages behind to - be replayed on the next turn. - - When the Agno agent manages its own history this returns empty — Agno - replays prior turns from its database. - """ - if self._agno_manages_history: - return [] - if is_session_bootstrap: - self._message_history[room_id] = list(history) - return list(self._message_history.setdefault(room_id, [])) - - def _build_run_input( - self, - msg: PlatformMessage, - history: AgnoMessages, - participants_msg: str | None, - contacts_msg: str | None, - *, - is_session_bootstrap: bool, - room_id: str, - ) -> list[Message]: - """Compose this turn's Agno input: prior transcript + injected messages. - - Built from a *copy* of the committed transcript (see - :meth:`_prior_transcript`), so building the input never mutates - ``_message_history`` and a failed run leaves no injected residue behind. - """ - messages = self._prior_transcript( - history, is_session_bootstrap=is_session_bootstrap, room_id=room_id - ) - - if participants_msg: - messages.append( - Message(role="user", content=f"[System]: {participants_msg}") - ) - if contacts_msg: - messages.append(Message(role="user", content=f"[System]: {contacts_msg}")) - messages.append(Message(role="user", content=msg.format_for_llm())) - return messages - - async def _run_agent( - self, - messages: list[Message], - tools: AgentToolsProtocol, - *, - room_id: str, - msg_id: str, - ) -> RunOutput | None: - """Run the Agno agent with the room's tools bound for this call. - - When ``Emit.EXECUTION`` is enabled the run is streamed so tool_call / - tool_result events are emitted *as each tool runs* (see - :meth:`_run_streamed`), matching the other adapters' live reporting. - Otherwise it runs non-streaming, exactly as before. - """ - agent = self._agent - if agent is None: - raise RuntimeError("AgnoAdapter was used before on_started()") - session_id = self._session_id_factory(room_id) - logger.debug( - "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", - room_id, - msg_id, - len(messages), - session_id, - ) - try: - with _bind_room_tools(tools): - if Emit.EXECUTION in self.features.emit: - response = await self._run_streamed( - agent, - messages, - tools, - session_id=session_id, - room_id=room_id, - msg_id=msg_id, - ) - else: - response = await agent.arun(input=messages, session_id=session_id) - except Exception: - # Keep the user-facing payload generic; the full traceback is in the - # agent log via logger.exception. Exception text can include DB - # strings, paths, and tokens that must not surface in chat. - logger.exception( - "Room %s msg %s: error running Agno agent", room_id, msg_id - ) - try: - await tools.send_event( - content="Internal error while processing message; see agent logs.", - message_type="error", - ) - except Exception: - logger.exception( - "Room %s msg %s: failed to report error event", room_id, msg_id - ) - raise - - if response is None: - logger.debug( - "Room %s msg %s: Agno agent returned no response", room_id, msg_id - ) - return response - - async def _run_streamed( - self, - agent: AgnoAgent, - messages: list[Message], - tools: AgentToolsProtocol, - *, - session_id: str, - room_id: str, - msg_id: str, - ) -> RunOutput | None: - """Stream the run, emitting tool events live, and return the final output. - - ``stream_events=True`` yields a ``ToolCallStartedEvent`` / - ``ToolCallCompletedEvent`` for every tool call (user-configured and - Band), and ``yield_run_output=True`` yields the assembled ``RunOutput`` - last. The ``_current_tools`` binding from :meth:`_run_agent` spans the - whole iteration, since tools execute as the stream is consumed. - """ - final: RunOutput | None = None - async for item in agent.arun( - input=messages, - session_id=session_id, - stream=True, - stream_events=True, - yield_run_output=True, - ): - if isinstance(item, RunOutput): - final = item - else: - await self._emit_stream_event( - item, tools, room_id=room_id, msg_id=msg_id - ) - return final - - def _persist_turn(self, room_id: str, response: RunOutput) -> None: - """Persist Agno's transcript, keeping only conversation messages. - - Allowlisting conversation roles drops Agno's per-run injected messages - (instructions, context, summaries) so they are not replayed alongside - the freshly injected ones on the next run. - """ - if response.messages: - self._message_history[room_id] = [ - m for m in response.messages if m.role in _CONVERSATION_ROLES - ] - - def _capture_user_tools(self, agent: AgnoAgent) -> None: - """Capture the user's own tools before installing the room factory. - - Replacing ``agent.tools`` with our per-run factory (see - :meth:`_resolve_room_tools`) would otherwise drop whatever tools the user - configured, so we stash them and re-include them on every run. A - user-supplied *callable* tools factory is kept as-is and resolved per run - with Agno's own semantics; a static list is copied. - """ - tools: Any = getattr(agent, "tools", None) - if tools is None: - self._user_tools = [] - elif is_callable_factory(tools, excluded_types=(Toolkit, Function)): - self._user_tools = tools # a per-run callable factory - else: - self._user_tools = list(tools) # a static list - - async def _resolve_room_tools(self, run_context: Any = None) -> list[Any]: - """Per-run tool factory: user tools + the active room's Band tools. - - Installed as ``agent.tools`` in :meth:`on_started`. Agno invokes it once - per run (its own cache disabled) via ``ainvoke_callable_factory`` and - resolves the result into that run's context rather than mutating shared - agent state -- so concurrent rooms never see each other's tools. The - active room is read from the ``_current_tools`` ContextVar bound around - ``arun`` in :meth:`_run_agent` (the same binding that routes tool - execution), keeping visibility and execution aligned. Band tools are - gated per room: the CONTACTS capability or a contact-hub room includes - the contact tools, so a normal room never sees them even after a hub room - has run. - """ - user_tools = await self._resolve_user_tools(run_context) - - active = _current_tools.get() - if active is None: - # Outside a bound run we cannot know the room; expose only the user's - # own tools rather than guessing Band tool visibility. - return user_tools - - include_contacts = Capability.CONTACTS in self.features.capabilities or bool( - getattr(active, "is_hub_room", False) - ) - band = self._band_tools_cache.get(include_contacts) - if band is None: - band = self._build_band_tools(active, include_contacts=include_contacts) - self._band_tools_cache[include_contacts] = band - return [*user_tools, *band] - - async def _resolve_user_tools(self, run_context: Any) -> list[Any]: - """Resolve the user's own tools for this run. - - A static list is returned as a fresh copy; a user-supplied callable - factory is invoked with Agno's own signature injection - (agent/run_context/session_state) and may be sync or async. - """ - if not callable(self._user_tools): - return list(self._user_tools) - - resolved = await ainvoke_callable_factory( - self._user_tools, self._agent, run_context - ) - return list(resolved) if resolved else [] - - def _inject_band_instructions(self) -> None: - """Append Band tool guidance to the runtime agent's ``additional_context``. - - Appending (rather than replacing) preserves the user's own - instructions. Called once at startup, before any room runs. - """ - if self._agent is None: - return - - guidance = self._band_instructions() - existing = getattr(self._agent, "additional_context", None) - self._agent.additional_context = ( - f"{existing}\n\n{guidance}" if existing else guidance - ) - - def _band_instructions(self) -> str: - """Compose the Band identity + guidance gated on enabled capabilities. - - Mirrors the other adapters via :func:`render_system_prompt`: prepends - "You are {name}, {description}." so the model knows its Band-registered - identity, then the base instructions and any capability sections. The - developer's own Agno ``instructions`` are preserved separately — this is - appended to ``additional_context`` (see :meth:`_inject_band_instructions`). - """ - return render_system_prompt( - agent_name=self.agent_name or "Agent", - agent_description=self.agent_description or "An AI assistant", - features=self.features, - ) - - def _build_band_tools( - self, tools: AgentToolsProtocol, *, include_contacts: bool - ) -> list[Function]: - """Convert Band tool schemas into Agno Functions. - - Honors the AdapterFeatures include/exclude/category filters via - :func:`filter_tool_schemas`. ``include_contacts`` is resolved by the - caller (CONTACTS capability or a contact-hub room, mirroring LangGraph) - so the built set can be cached on that flag. - """ - schemas = tools.get_openai_tool_schemas( - include_memory=Capability.MEMORY in self.features.capabilities, - include_contacts=include_contacts, - ) - schemas = filter_tool_schemas( - schemas, - self.features, - get_name=lambda s: s.get("function", {}).get("name", ""), - get_category=lambda s: get_band_tool_category( - s.get("function", {}).get("name", "") - ), - ) - - band_tools: list[Function] = [] - for schema in schemas: - fn = schema.get("function", {}) - if name := fn.get("name"): - band_tools.append( - Function( - name=name, - description=fn.get("description", "") or "", - parameters=fn.get("parameters") - or {"type": "object", "properties": {}}, - entrypoint=_make_band_entrypoint(name), - skip_entrypoint_processing=True, - ) - ) - return band_tools - - @classmethod - async def _report_thoughts( - cls, - response: RunOutput, - tools: AgentToolsProtocol, - *, - room_id: str, - msg_id: str, - ) -> None: - """Post Agno reasoning as a thought event.""" - reasoning = getattr(response, "reasoning_content", None) - text = (reasoning or "").strip() if isinstance(reasoning, str) else "" - if not text: - return - - logger.info( - "Room %s msg %s: reporting reasoning as thought (%d chars)", - room_id, - msg_id, - len(text), - ) - try: - await tools.send_event(content=text, message_type="thought") - except Exception as e: - logger.warning( - "Room %s msg %s: failed to report thought: %s", room_id, msg_id, e - ) - - @classmethod - async def _emit_stream_event( - cls, - item: RunOutputEvent, - tools: AgentToolsProtocol, - *, - room_id: str, - msg_id: str, - ) -> None: - """Emit a tool_call / tool_result event for one streamed run event. - - Agno yields a started + completed event (each carrying a - ``ToolExecution``) for every tool call -- user-configured and Band - alike. Self-reporting tools already produce visible room output, so they - are skipped. The completed event carries ``result`` + ``tool_call_error``, - so exactly one tool_result is emitted per call whether it succeeded or - failed; all other events (content deltas, reasoning, ``ToolCallErrorEvent``) - fall through and are ignored. - """ - if ( - isinstance(item, ToolCallStartedEvent) - and (ex := item.tool) is not None - and ex.tool_name not in _SELF_REPORTING_TOOLS - ): - await cls._emit_tool_event( - tools, - "tool_call", - { - "name": ex.tool_name or "", - "args": ex.tool_args or {}, - "tool_call_id": ex.tool_call_id or "", - }, - room_id=room_id, - msg_id=msg_id, - ) - elif ( - isinstance(item, ToolCallCompletedEvent) - and (ex := item.tool) is not None - and ex.tool_name not in _SELF_REPORTING_TOOLS - ): - await cls._emit_tool_event( - tools, - "tool_result", - { - "name": ex.tool_name or "", - "output": str(ex.result or ""), - "tool_call_id": ex.tool_call_id or "", - "is_error": bool(ex.tool_call_error), - }, - room_id=room_id, - msg_id=msg_id, - ) - - @staticmethod - async def _emit_tool_event( - tools: AgentToolsProtocol, - message_type: str, - payload: dict[str, Any], - *, - room_id: str, - msg_id: str, - ) -> None: - """Send one tool event, logging (never raising) on failure.""" - logger.debug("Room %s msg %s: %s %s", room_id, msg_id, message_type, payload) - try: - await tools.send_event( - content=json.dumps(payload), message_type=message_type - ) - except Exception as e: - logger.warning( - "Room %s msg %s: failed to report %s %s: %s", - room_id, - msg_id, - message_type, - payload.get("name"), - e, - ) diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index db4158360..a0f974efd 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -25,7 +25,6 @@ from band.core.exceptions import BandConfigError from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter -from band.core.tool_filter import sanitize_tool_schema from band.core.types import ( AdapterFeatures, Capability, @@ -414,10 +413,8 @@ def _build_gemini_tools(self, tools: AgentToolsProtocol) -> list[types.Tool]: name = function.get("name") if not name: continue - parameters = sanitize_tool_schema( - function.get("parameters", {"type": "object", "properties": {}}), - drop_numeric_bounds=True, - drop_additional_properties=True, + parameters = function.get( + "parameters", {"type": "object", "properties": {}} ) declarations.append( types.FunctionDeclaration( @@ -430,9 +427,6 @@ def _build_gemini_tools(self, tools: AgentToolsProtocol) -> list[types.Tool]: for input_model, _func in self._custom_tools: schema = input_model.model_json_schema() schema.pop("title", None) - schema = sanitize_tool_schema( - schema, drop_numeric_bounds=True, drop_additional_properties=True - ) tool_name = get_custom_tool_name(input_model) declarations.append( types.FunctionDeclaration( diff --git a/src/band/adapters/google_adk.py b/src/band/adapters/google_adk.py index a4b95f1dc..c445305c6 100644 --- a/src/band/adapters/google_adk.py +++ b/src/band/adapters/google_adk.py @@ -21,7 +21,6 @@ from band.core.exceptions import BandConfigError from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter -from band.core.tool_filter import sanitize_tool_schema from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage from band.converters.google_adk import GoogleADKHistoryConverter, GoogleADKMessages from band.runtime.custom_tools import ( @@ -92,6 +91,37 @@ def _require_adk() -> tuple[type, type, type, Any]: return ADKAgent, InMemoryRunner, BaseTool, types +def _strip_additional_properties( + openai_params: dict[str, Any] | list[Any] | Any, +) -> Any: + """Convert OpenAI JSON Schema parameters to Gemini format. + + Gemini does not support the ``additionalProperties`` key in function + parameter schemas. Passing it causes ``google.genai`` to reject the + declaration with a validation error. This helper strips the key + recursively so the schema is compatible. + """ + if isinstance(openai_params, list): + return [ + _strip_additional_properties(item) + if isinstance(item, (dict, list)) + else item + for item in openai_params + ] + if not isinstance(openai_params, dict): + return openai_params + + cleaned: dict[str, Any] = {} + for key, value in openai_params.items(): + if key == "additionalProperties": + continue + if isinstance(value, (dict, list)): + cleaned[key] = _strip_additional_properties(value) + else: + cleaned[key] = value + return cleaned + + @functools.lru_cache(maxsize=1) def _get_tool_bridge_class() -> type: """Build the ``_BandToolBridge`` class lazily. @@ -163,11 +193,7 @@ def __init__( self._cached_declaration = types.FunctionDeclaration( name=tool_name, description=tool_description, - parameters=sanitize_tool_schema( - parameters_schema, - drop_numeric_bounds=True, - drop_additional_properties=True, - ), + parameters=_strip_additional_properties(parameters_schema), ) except Exception as exc: raise RuntimeError( diff --git a/src/band/converters/__init__.py b/src/band/converters/__init__.py index 94a21c397..291475a70 100644 --- a/src/band/converters/__init__.py +++ b/src/band/converters/__init__.py @@ -64,10 +64,6 @@ from band.converters.acp_client import ( ACPClientHistoryConverter as ACPClientHistoryConverter, ) - from band.converters.agno import ( - AgnoHistoryConverter as AgnoHistoryConverter, - AgnoMessages as AgnoMessages, - ) from band.converters.gemini import ( GeminiHistoryConverter as GeminiHistoryConverter, GeminiMessages as GeminiMessages, @@ -99,8 +95,6 @@ "CodexHistoryConverter", "ACPServerHistoryConverter", "ACPClientHistoryConverter", - "AgnoHistoryConverter", - "AgnoMessages", "GeminiHistoryConverter", "GeminiMessages", "GoogleADKHistoryConverter", @@ -189,13 +183,6 @@ def __getattr__(name: str) -> type: from band.converters.codex import CodexHistoryConverter return CodexHistoryConverter - elif name in ("AgnoHistoryConverter", "AgnoMessages"): - from band.converters.agno import AgnoHistoryConverter, AgnoMessages - - if name == "AgnoHistoryConverter": - return AgnoHistoryConverter - return AgnoMessages - elif name in ("GeminiHistoryConverter", "GeminiMessages"): from band.converters.gemini import GeminiHistoryConverter, GeminiMessages diff --git a/src/band/converters/agno.py b/src/band/converters/agno.py deleted file mode 100644 index bd4c63be8..000000000 --- a/src/band/converters/agno.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Agno history converter.""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from band.core.protocols import HistoryConverter - -from ._tool_parsing import parse_tool_call, parse_tool_result - -try: - from agno.models.message import Message -except ImportError as e: - raise ImportError( - "agno is required for the Agno converter.\n" - "Install with: pip install 'band-sdk[agno]'" - ) from e - -logger = logging.getLogger(__name__) - -AgnoMessages = list[Message] - - -def _flush_tool_calls( - messages: AgnoMessages, pending_calls: list[dict[str, Any]] -) -> None: - if not pending_calls: - return - messages.append( - Message( - role="assistant", - content=None, - tool_calls=list(pending_calls), - from_history=True, - ) - ) - pending_calls.clear() - - -class AgnoHistoryConverter(HistoryConverter[AgnoMessages]): - """Convert platform history to Agno messages.""" - - def __init__(self, agent_name: str = "") -> None: - self._agent_name = agent_name - - def set_agent_name(self, name: str) -> None: - self._agent_name = name - - def convert(self, raw: list[dict[str, Any]]) -> AgnoMessages: - messages: AgnoMessages = [] - pending_calls: list[dict[str, Any]] = [] - - for hist in raw: - match hist.get("message_type", "text"): - case "tool_call": - call = self._tool_call_dict(hist.get("content", "")) - if call is not None: - pending_calls.append(call) - case "tool_result": - _flush_tool_calls(messages, pending_calls) - self._append_tool_result(messages, hist.get("content", "")) - case "text": - _flush_tool_calls(messages, pending_calls) - messages.append(self._text_message(hist)) - case _: - pass - - _flush_tool_calls(messages, pending_calls) - logger.debug( - "Converted %d platform event(s) into %d Agno message(s)", - len(raw), - len(messages), - ) - return messages - - @staticmethod - def _tool_call_dict(content: str) -> dict[str, Any] | None: - parsed = parse_tool_call(content) - if parsed is None: - return None - return { - "id": parsed.tool_call_id, - "type": "function", - "function": { - "name": parsed.name, - "arguments": json.dumps(parsed.args), - }, - } - - @staticmethod - def _append_tool_result(messages: AgnoMessages, content: str) -> None: - parsed = parse_tool_result(content) - if parsed is None: - return - messages.append( - Message( - role="tool", - tool_call_id=parsed.tool_call_id, - tool_name=parsed.name, - content=parsed.output, - tool_call_error=parsed.is_error, - from_history=True, - ) - ) - - def _text_message(self, hist: dict[str, Any]) -> Message: - # Converter output is rehydrated history; tag it so Agno's - # any(msg.from_history) check doesn't re-add stored session history. - content = hist.get("content", "") - # Own-agent detection keys on sender_name, not a stable sender_id: - # formatted history dicts carry only sender_name (see - # band.runtime.formatters.format_message_for_llm). If two participants - # share a display name, or this agent is renamed, prior assistant turns - # may be mis-mapped to the user role. - if hist.get("role") == "assistant" and hist.get("sender_name") == ( - self._agent_name - ): - return Message(role="assistant", content=content, from_history=True) - - sender_name = hist.get("sender_name", "") - formatted = f"[{sender_name}]: {content}" if sender_name else content - return Message(role="user", content=formatted, from_history=True) diff --git a/src/band/core/tool_filter.py b/src/band/core/tool_filter.py index 8e39eb6c2..98461bf16 100644 --- a/src/band/core/tool_filter.py +++ b/src/band/core/tool_filter.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Any, Callable, TypeVar +from typing import Callable, TypeVar from band.core.types import AdapterFeatures @@ -15,70 +15,6 @@ T = TypeVar("T") -# Numeric-range JSON-Schema keywords. Some providers reject these on integer or -# number parameters in tool/function schemas (e.g. Gemini, and Anthropic-backed -# Agno: "For 'integer' type, property 'maximum' is not supported"). -_NUMERIC_BOUND_KEYWORDS = frozenset( - {"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"} -) - -# JSON-Schema keywords whose values are maps of *arbitrary property names* to -# subschemas. Their child keys are names, not keywords, so they must never be -# stripped (a tool param literally named ``maximum`` must survive). -_NAME_MAP_KEYWORDS = frozenset( - {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} -) - - -def sanitize_tool_schema( - schema: Any, - *, - drop_numeric_bounds: bool = False, - drop_additional_properties: bool = False, -) -> Any: - """Recursively remove JSON-Schema keywords that some providers reject. - - Returns a new structure; the input is left untouched. Centralizes the - schema scrubbing that model adapters need before handing Band tool schemas - to a provider that rejects otherwise-valid JSON Schema. - - Args: - schema: A JSON-Schema dict (or any nested fragment of one). - drop_numeric_bounds: Drop ``minimum``/``maximum``/``exclusiveMinimum``/ - ``exclusiveMaximum``/``multipleOf``. The bounds remain enforced - wherever tool-call arguments are validated against the source model. - drop_additional_properties: Drop ``additionalProperties`` (rejected by - Gemini). - - Keys are stripped only where they act as schema keywords, never where they - are property names under ``properties``/``$defs``/etc. - """ - drop: set[str] = set() - if drop_numeric_bounds: - drop |= _NUMERIC_BOUND_KEYWORDS - if drop_additional_properties: - drop.add("additionalProperties") - return _sanitize(schema, drop) - - -def _sanitize(node: Any, drop: frozenset[str] | set[str]) -> Any: - if isinstance(node, list): - return [_sanitize(item, drop) for item in node] - if not isinstance(node, dict): - return node - cleaned: dict[str, Any] = {} - for key, value in node.items(): - if key in drop: - continue - if key in _NAME_MAP_KEYWORDS and isinstance(value, dict): - # Values here are name -> subschema; keep names, scrub each subschema. - cleaned[key] = { - name: _sanitize(subschema, drop) for name, subschema in value.items() - } - else: - cleaned[key] = _sanitize(value, drop) - return cleaned - def filter_tool_schemas( schemas: list[T], diff --git a/src/band/integrations/langgraph/langchain_tools.py b/src/band/integrations/langgraph/langchain_tools.py index ab29d1336..d8eca2921 100644 --- a/src/band/integrations/langgraph/langchain_tools.py +++ b/src/band/integrations/langgraph/langchain_tools.py @@ -18,8 +18,10 @@ from band.core.tool_filter import filter_tool_schemas from band.core.types import AdapterFeatures, Capability from band.runtime.tools import ( + CHAT_TOOL_NAMES, + CONTACT_TOOL_NAMES, + MEMORY_TOOL_NAMES, format_tool_validation_error, - get_band_tool_category, get_tool_description, iter_tool_definitions, ) @@ -27,6 +29,18 @@ logger = logging.getLogger(__name__) +_TOOL_CATEGORIES: dict[str, str] = { + **{name: "chat" for name in CHAT_TOOL_NAMES}, + **{name: "contacts" for name in CONTACT_TOOL_NAMES}, + **{name: "memory" for name in MEMORY_TOOL_NAMES}, +} + + +def get_langgraph_tool_category(name: str) -> str | None: + """Return the AdapterFeatures category for a LangGraph platform tool.""" + return _TOOL_CATEGORIES.get(name) + + def agent_tools_to_langchain( tools: AgentToolsProtocol, *, @@ -84,7 +98,7 @@ def agent_tools_to_langchain( definitions, features, get_name=lambda definition: definition.name, - get_category=lambda definition: get_band_tool_category(definition.name), + get_category=lambda definition: get_langgraph_tool_category(definition.name), ) platform_tools: list[Any] = [] diff --git a/src/band/runtime/prompts.py b/src/band/runtime/prompts.py index e72c8ca3d..adf3c32b1 100644 --- a/src/band/runtime/prompts.py +++ b/src/band/runtime/prompts.py @@ -95,10 +95,9 @@ def _memory_type_lines() -> str: _MEMORY_SCOPE_GUIDANCE = f"""Prefer `scope="{MemoryStoreScope.SUBJECT.value}"` whenever the memory is about a specific person or agent, so it stays attached to that subject rather than leaking org-wide. Storing with `scope="{MemoryStoreScope.SUBJECT.value}"` requires a -real `subject_id` UUID: for someone in the current room (e.g. the user you are talking to), call -`band_get_participants` and use their `id`; for someone not in the room, use `band_lookup_peers`. -Reserve `scope="{MemoryStoreScope.ORGANIZATION.value}"` for knowledge that is genuinely shared across the whole organization -and is not about any one subject (e.g. cross-room memories not tied to one subject). +real `subject_id` UUID, so resolve it first via `band_lookup_peers` or the participant list. +Reserve `scope="{MemoryStoreScope.ORGANIZATION.value}"` for knowledge that is genuinely shared across the whole organization and +is not about any one subject. """ diff --git a/src/band/runtime/tools.py b/src/band/runtime/tools.py index 48995bc95..900f7fe76 100644 --- a/src/band/runtime/tools.py +++ b/src/band/runtime/tools.py @@ -28,7 +28,6 @@ validate_subject_scope, ) from band.core.protocols import AgentToolsProtocol -from band.core.tool_filter import sanitize_tool_schema from band.core.types import EventMessageType if TYPE_CHECKING: @@ -1028,19 +1027,6 @@ class ListMyPeersInput(BaseModel): CHAT_TOOL_NAMES: frozenset[str] = BASE_TOOL_NAMES - CONTACT_TOOL_NAMES MCP_TOOL_PREFIX: str = "mcp__band__" -# AdapterFeatures category for each platform tool name. Shared across adapters -# so include_categories filtering is consistent (chat/contacts/memory). -_TOOL_CATEGORIES: dict[str, str] = { - **{name: "chat" for name in CHAT_TOOL_NAMES}, - **{name: "contacts" for name in CONTACT_TOOL_NAMES}, - **{name: "memory" for name in MEMORY_TOOL_NAMES}, -} - - -def get_band_tool_category(name: str) -> str | None: - """Return the AdapterFeatures category ("chat"/"contacts"/"memory") for a tool.""" - return _TOOL_CATEGORIES.get(name) - def mcp_tool_names(names: frozenset[str]) -> list[str]: """Convert base tool names to MCP-prefixed names for Claude SDK. @@ -2147,12 +2133,6 @@ def get_tool_schemas( schema = definition.input_model.model_json_schema() # Remove Pydantic-specific keys schema.pop("title", None) - # Pydantic Field(ge=..., le=...) renders as JSON-Schema minimum/maximum, - # which some providers reject on integer params (e.g. Gemini, and - # Anthropic-backed Agno). Dropped for every format/adapter on purpose, - # not just the strict providers: the bounds stay enforced at execution - # via model_validate, so advertising them buys nothing. - schema = sanitize_tool_schema(schema, drop_numeric_bounds=True) if format == "openai": tools.append( diff --git a/tests/adapters/agno/__init__.py b/tests/adapters/agno/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/adapters/agno/conftest.py b/tests/adapters/agno/conftest.py deleted file mode 100644 index c0e5fd9be..000000000 --- a/tests/adapters/agno/conftest.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Shared fixtures for the Agno adapter tests. - -(``sample_platform_message`` comes from the root ``tests/conftest.py``.) -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agno.agent import Agent as AgnoAgent -from agno.run.agent import RunOutput - -from band.adapters.agno import AgnoAdapter -from band.core.types import AdapterFeatures, PlatformMessage -from band.testing import FakeAgentTools - -from tests.adapters.agno.helpers import CapturingModel, SchemaTools - - -@pytest.fixture -def tools() -> FakeAgentTools: - """A fresh, call-tracking Band tool surface for one test.""" - return FakeAgentTools() - - -@pytest.fixture -def make_agno_agent() -> Callable[..., MagicMock]: - """Factory returning a configured Agno agent fake. - - The adapter runs against this instance directly, so it carries the - history/memory config the guards read and its ``arun`` yields ``response``. - """ - - def _make( - *, - update_memory_on_run: bool = False, - enable_agentic_memory: bool = False, - add_history_to_context: bool = False, - db: object | None = None, - response: RunOutput | None = None, - events: list[Any] | None = None, - ) -> MagicMock: - agent = MagicMock(name="agno_agent") - agent.update_memory_on_run = update_memory_on_run - agent.enable_agentic_memory = enable_agentic_memory - # Explicit falsy defaults: a bare MagicMock would expose these as truthy - # auto-attributes and spuriously trip the history-management guard. - agent.add_history_to_context = add_history_to_context - agent.db = db - agent.add_tool = MagicMock() - # Real Agno agents default additional_context to None; mirror that. - agent.additional_context = None - # The adapter captures the user's tools at startup, then installs a - # callable factory. A bare MagicMock `.tools` is itself callable and would - # be mistaken for a user-supplied tools factory, so pin it to a list. - agent.tools = [] - resp = response if response is not None else RunOutput() - if events is None: - # Non-streaming path (Emit.EXECUTION off): `await agent.arun(...)`. - agent.arun = AsyncMock(return_value=resp) - else: - # Streaming path (Emit.EXECUTION on): the adapter iterates - # `agent.arun(stream=True, ...)`, which yields the run events then the - # final RunOutput. A bare MagicMock returns an async iterator without - # awaiting, matching how the adapter consumes the stream. - def _arun(*args: Any, **kwargs: Any) -> Any: - async def _stream() -> Any: - for event in events: - yield event - yield resp - - return _stream() - - agent.arun = MagicMock(side_effect=_arun) - return agent - - return _make - - -@pytest.fixture -def make_started_adapter( - make_agno_agent: Callable[..., MagicMock], -) -> Callable[..., Awaitable[tuple[AgnoAdapter, MagicMock]]]: - """Factory building an adapter past ``on_started``; returns - ``(adapter, agent)``.""" - - async def _make( - response: RunOutput | None = None, - *, - features: AdapterFeatures | None = None, - add_history_to_context: bool = False, - db: object | None = None, - events: list[Any] | None = None, - ) -> tuple[AgnoAdapter, MagicMock]: - agent = make_agno_agent( - response=response, - add_history_to_context=add_history_to_context, - db=db, - events=events, - ) - adapter = AgnoAdapter(agent, features=features) - await adapter.on_started("TestBot", "desc") - return adapter, agent - - return _make - - -@pytest.fixture -def run_real_agent() -> Callable[..., Awaitable[CapturingModel]]: - """Factory that drives one bootstrap turn through a real Agno agent and a - capturing model, returning the model so a test can inspect the system prompt - Agno actually assembled and sent.""" - - async def _run( - msg: PlatformMessage, - *, - instructions: str = "You are Dev.", - additional_context: str | None = None, - features: AdapterFeatures | None = None, - ) -> CapturingModel: - agno = AgnoAgent( - model=CapturingModel(), - instructions=instructions, - additional_context=additional_context, - ) - adapter = AgnoAdapter(agno, features=features) - await adapter.on_started("Bot", "desc") - await adapter.on_message( - msg, - SchemaTools([]), - [], - None, - None, - is_session_bootstrap=True, - room_id=msg.room_id, - ) - assert adapter.agent is not None - model = adapter.agent.model - assert isinstance(model, CapturingModel) - return model - - return _run diff --git a/tests/adapters/agno/helpers.py b/tests/adapters/agno/helpers.py deleted file mode 100644 index b2f1d512b..000000000 --- a/tests/adapters/agno/helpers.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Shared helpers for the Agno adapter tests. - -The adapter never calls an LLM directly: it configures the developer's Agno -agent in ``on_started`` and calls ``agent.arun(...)`` per turn. So the only thing -faked here is the Agno agent (``add_tool`` / ``arun``); everything -the adapter reads off the run is a real Agno ``RunOutput`` / ``Message`` / -``ToolExecution``. The Band side uses ``FakeAgentTools`` so calls are tracked -without a mocking framework. -""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock - -from agno.models.base import Model -from agno.models.message import Message -from agno.models.response import ModelResponse, ToolExecution -from agno.run.agent import ToolCallCompletedEvent, ToolCallStartedEvent - -from band.core.types import ( - AgentInput, - HistoryProvider, - PlatformMessage, -) -from band.testing import FakeAgentTools - - -def tool_execution( - name: str, - *, - call_id: str = "tc_1", - args: dict[str, Any] | None = None, - result: str = "", - error: bool = False, -) -> ToolExecution: - return ToolExecution( - tool_name=name, - tool_call_id=call_id, - tool_args=args or {}, - result=result, - tool_call_error=error, - ) - - -def tool_events(execution: ToolExecution) -> list[Any]: - """The started + completed stream events Agno yields for one tool call. - - Mirrors a streamed ``arun`` (``stream_events=True``): the started event - carries name/args/id; the completed event carries result/error. The same - ``ToolExecution`` instance is used for both, as Agno mutates and re-emits it. - """ - return [ - ToolCallStartedEvent(tool=execution), - ToolCallCompletedEvent(tool=execution), - ] - - -class CapturingModel(Model): - """A real Agno model that records the messages Agno asks it to respond to. - - Lets tests assert on the actual system prompt Agno assembles (the agent's - own instructions plus ``additional_context``), rather than the attribute the - adapter sets. Overriding ``aresponse`` skips the provider call path, so the - abstract invoke hooks are inert stubs. - """ - - def __init__(self, content: str = "ok") -> None: - super().__init__(id="capturing", provider="fake") - self._content = content - self.captured_messages: list[Message] | None = None - # Tool names Agno offered the model on the most recent response call, - # so tests can assert per-run tool exposure end-to-end. - self.captured_tool_names: list[str] | None = None - - def invoke(self, *args: Any, **kwargs: Any) -> Any: ... - async def ainvoke(self, *args: Any, **kwargs: Any) -> Any: ... - def invoke_stream(self, *args: Any, **kwargs: Any) -> Any: ... - async def ainvoke_stream(self, *args: Any, **kwargs: Any) -> Any: ... - def _parse_provider_response(self, *args: Any, **kwargs: Any) -> Any: ... - def _parse_provider_response_delta(self, *args: Any, **kwargs: Any) -> Any: ... - - async def aresponse(self, messages: list[Message], **kwargs: Any) -> ModelResponse: - self.captured_messages = messages - self.captured_tool_names = [ - _tool_schema_name(t) for t in (kwargs.get("tools") or []) - ] - return ModelResponse(content=self._content) - - @property - def captured_system_prompt(self) -> str: - """The concatenated system message(s) Agno sent to the model.""" - messages = self.captured_messages or [] - return "\n".join( - m.content for m in messages if m.role == "system" and m.content - ) - - -def _tool_schema_name(tool: Any) -> str | None: - """Best-effort tool name from an Agno Function or an OpenAI-format dict.""" - name = getattr(tool, "name", None) - if name: - return name - if isinstance(tool, dict): - return tool.get("function", {}).get("name") or tool.get("name") - return None - - -class SchemaTools(FakeAgentTools): - """FakeAgentTools that returns real OpenAI-format schemas and records the - capability flags it was asked for (FakeAgentTools returns [] by default).""" - - def __init__(self, schemas: list[dict[str, Any]], **kwargs: Any) -> None: - super().__init__(**kwargs) - self._schemas = schemas - self.schema_calls: list[dict[str, bool]] = [] - - def get_openai_tool_schemas( - self, *, include_memory: bool = False, include_contacts: bool = True - ) -> list[dict[str, Any]]: - self.schema_calls.append( - {"include_memory": include_memory, "include_contacts": include_contacts} - ) - return self._schemas - - -class ContactAwareTools(SchemaTools): - """Like real AgentTools: contact tool schemas appear only when contacts are - requested. Always exposes ``band_send_message``; adds ``band_add_contact`` - when ``include_contacts`` is True (CONTACTS capability or a hub room).""" - - def __init__(self, **kwargs: Any) -> None: - super().__init__([], **kwargs) - - def get_openai_tool_schemas( - self, *, include_memory: bool = False, include_contacts: bool = True - ) -> list[dict[str, Any]]: - self.schema_calls.append( - {"include_memory": include_memory, "include_contacts": include_contacts} - ) - schemas = [openai_tool_schema("band_send_message")] - if include_contacts: - schemas.append(openai_tool_schema("band_add_contact")) - return schemas - - -def openai_tool_schema(name: str) -> dict[str, Any]: - return { - "type": "function", - "function": { - "name": name, - "description": f"{name} tool", - "parameters": {"type": "object", "properties": {}}, - }, - } - - -def make_agent_input( - msg: PlatformMessage, - raw: list[dict[str, Any]], - *, - is_session_bootstrap: bool, - participants_msg: str | None = None, - contacts_msg: str | None = None, - tools: FakeAgentTools | None = None, -) -> AgentInput: - """Build an AgentInput so tests drive the real on_event -> converter path.""" - return AgentInput( - msg=msg, - tools=tools or FakeAgentTools(), - history=HistoryProvider(raw=raw), - participants_msg=participants_msg, - contacts_msg=contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=msg.room_id, - ) - - -def run_input(copy: MagicMock) -> list[Message]: - """The exact list[Message] the faked Agno agent received via arun(input=...).""" - return copy.arun.await_args.kwargs["input"] - - -def platform_msg( - msg_id: str, - content: str, - *, - sender_type: str = "User", - sender_name: str = "Alice", - message_type: str = "text", -) -> dict[str, Any]: - """A platform-shaped history dict, as the REST context API would return it.""" - return { - "id": msg_id, - "content": content, - "sender_id": f"id-{msg_id}", - "sender_type": sender_type, - "sender_name": sender_name, - "message_type": message_type, - "metadata": {}, - } diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py deleted file mode 100644 index 7a4049032..000000000 --- a/tests/adapters/agno/test_adapter.py +++ /dev/null @@ -1,891 +0,0 @@ -"""Agno adapter behavior tests. - -Conformance already covers init defaults, ``on_started`` name/description, and -generic converter wiring; these tests pin Agno-only behavior: running against -the given agent, memory-collision warning, per-run Band-tool resolution (the -callable-tools factory + ContextVar binding), strict per-room tool visibility, -fallback-send, emit reporting, transcript persistence, and cleanup. Rehydration -of platform history lives in ``test_rehydration.py``. -""" - -from __future__ import annotations - -import json -import warnings -from datetime import datetime, timezone -from typing import Any -from unittest.mock import AsyncMock - -import pytest -from agno.agent import Agent as AgnoAgent -from agno.models.message import Message -from agno.run.agent import RunOutput - -from band.adapters.agno import ( - AgnoAdapter, - _bind_room_tools, - _make_band_entrypoint, -) -from band.core.types import AdapterFeatures, Capability, Emit, PlatformMessage -from band.testing import FakeAgentTools - -from tests.adapters.agno.helpers import ( - CapturingModel, - ContactAwareTools, - SchemaTools, - openai_tool_schema, - run_input, - tool_events, - tool_execution, -) - - -def _msg( - room_id: str, - content: str, - *, - msg_id: str = "m1", - sender_id: str = "user-1", -) -> PlatformMessage: - """A minimal PlatformMessage for driving on_message in a given room.""" - return PlatformMessage( - id=msg_id, - room_id=room_id, - content=content, - sender_id=sender_id, - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(timezone.utc), - ) - - -class TestOnStarted: - async def test_runs_against_the_given_agent(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - - await adapter.on_started("TestBot", "desc") - - # The adapter uses the caller's instance directly, no copy. - assert adapter.agent is agent - - async def test_syncs_converter_identity(self, make_started_adapter): - adapter, _ = await make_started_adapter() - - assert adapter.history_converter._agent_name == "TestBot" - - async def test_runs_the_given_agent_on_a_message(self, make_agno_agent, tools): - # End-to-end: the given agent must be the one actually run on a message. - # The adapter delivers nothing on its own; plain agent text is not sent - # (only a ``band_send_message`` tool call reaches the room). - agent = make_agno_agent(response=RunOutput(content="hi there")) - adapter = AgnoAdapter(agent) - await adapter.on_started("TestBot", "desc") - - await adapter.on_message( - _msg("room-1", "hello"), - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - agent.arun.assert_awaited_once() - tools.assert_no_messages_sent() - - -class TestMemoryCollisionWarning: - """Collision is detected against the runtime agent at startup, not __init__.""" - - async def test_warns_on_update_memory_on_run_with_memory_capability( - self, make_agno_agent - ): - agent = make_agno_agent(update_memory_on_run=True) - adapter = AgnoAdapter( - agent, features=AdapterFeatures(capabilities={Capability.MEMORY}) - ) - - with pytest.warns(UserWarning, match="update_memory_on_run"): - await adapter.on_started("TestBot", "desc") - - async def test_warns_on_agentic_memory_with_memory_capability( - self, make_agno_agent - ): - agent = make_agno_agent(enable_agentic_memory=True) - adapter = AgnoAdapter( - agent, features=AdapterFeatures(capabilities={Capability.MEMORY}) - ) - - with pytest.warns(UserWarning, match="enable_agentic_memory"): - await adapter.on_started("TestBot", "desc") - - async def test_no_warning_without_memory_capability(self, make_agno_agent): - agent = make_agno_agent(update_memory_on_run=True, enable_agentic_memory=True) - adapter = AgnoAdapter(agent) # no MEMORY capability -> no collision - - with warnings.catch_warnings(): - warnings.simplefilter("error") - await adapter.on_started("TestBot", "desc") - - -class TestRoomToolResolution: - """Band tools are exposed per-run via the ``_resolve_room_tools`` factory - Agno calls each turn, not wired onto the agent. These pin what that factory - returns and the schema requests it makes for the active room.""" - - async def test_resolves_band_tools_for_active_room(self, make_started_adapter): - tools = SchemaTools( - [ - openai_tool_schema("band_send_message"), - openai_tool_schema("band_lookup_peers"), - ] - ) - adapter, _ = await make_started_adapter() - - with _bind_room_tools(tools): - resolved = await adapter._resolve_room_tools() - - assert [fn.name for fn in resolved] == [ - "band_send_message", - "band_lookup_peers", - ] - - async def test_no_band_tools_outside_a_bound_room(self, make_started_adapter): - # Defensive: with no active room bound, the factory exposes no Band tools - # (and does not even request schemas) rather than guessing visibility. - tools = SchemaTools([openai_tool_schema("band_send_message")]) - adapter, _ = await make_started_adapter() - - resolved = await adapter._resolve_room_tools() # no _bind_room_tools - - assert resolved == [] - assert tools.schema_calls == [] - - async def test_capability_flags_drive_schema_request(self, make_started_adapter): - tools = SchemaTools([]) - adapter, _ = await make_started_adapter( - features=AdapterFeatures( - capabilities={Capability.MEMORY, Capability.CONTACTS} - ) - ) - - with _bind_room_tools(tools): - await adapter._resolve_room_tools() - - assert tools.schema_calls == [ - {"include_memory": True, "include_contacts": True} - ] - - async def test_schema_build_is_cached_across_runs(self, make_started_adapter): - # Same contact flag across runs -> schemas are built once and reused, - # not rebuilt every turn. - tools = SchemaTools([openai_tool_schema("band_send_message")]) - adapter, _ = await make_started_adapter() - - with _bind_room_tools(tools): - await adapter._resolve_room_tools() - await adapter._resolve_room_tools() - await adapter._resolve_room_tools() - - assert tools.schema_calls == [ - {"include_memory": False, "include_contacts": False} - ] - - async def test_user_tools_are_reincluded(self, make_agno_agent): - # Replacing agent.tools with our factory must not drop the user's own - # tools; they are re-included alongside the room's Band tools. - user_tool = object() - agent = make_agno_agent() - agent.tools = [user_tool] - adapter = AgnoAdapter(agent) - await adapter.on_started("TestBot", "desc") - - tools = SchemaTools([openai_tool_schema("band_send_message")]) - with _bind_room_tools(tools): - resolved = await adapter._resolve_room_tools() - - assert resolved[0] is user_tool - assert [getattr(t, "name", None) for t in resolved[1:]] == ["band_send_message"] - - -class TestBandInstructionInjection: - """Drive a real Agno agent so we assert on the system prompt Agno actually - assembled and sent to the model, not the attribute the adapter set.""" - - @pytest.mark.parametrize( - ("capabilities", "present", "absent"), - [ - (set(), [], ["## Memory Tools", "## Contact Management Tools"]), - ( - {Capability.MEMORY}, - ["## Memory Tools"], - ["## Contact Management Tools"], - ), - ( - {Capability.CONTACTS}, - ["## Contact Management Tools"], - ["## Memory Tools"], - ), - ], - ) - async def test_capability_sections_gated_in_model_prompt( - self, run_real_agent, sample_platform_message, capabilities, present, absent - ): - model = await run_real_agent( - sample_platform_message, - features=AdapterFeatures(capabilities=capabilities), - ) - prompt = model.captured_system_prompt - - assert "## Environment" in prompt # base guidance always injected - assert all(section in prompt for section in present) - assert all(section not in prompt for section in absent) - - async def test_developer_instructions_survive_in_prompt( - self, run_real_agent, sample_platform_message - ): - model = await run_real_agent( - sample_platform_message, - instructions="You are Dev, a niche specialist.", - additional_context="Keep replies under 10 words.", - ) - prompt = model.captured_system_prompt - - assert "You are Dev, a niche specialist." in prompt - assert "Keep replies under 10 words." in prompt - assert "## Environment" in prompt - - async def test_guidance_injected_at_startup_before_any_message( - self, make_started_adapter - ): - # Band guidance is injected in on_started, not lazily on first message. - adapter, agent = await make_started_adapter() - - assert isinstance(agent.additional_context, str) - assert "## Environment" in agent.additional_context - # Band-registered identity is injected so the model knows who it is. - assert "You are TestBot, desc." in agent.additional_context - - -class TestBandEntrypointBinding: - async def test_routes_to_execute_tool_call_inside_context(self, tools): - entry = _make_band_entrypoint("band_lookup_peers") - - with _bind_room_tools(tools): - result = await entry(page=1) - - assert tools.tool_calls == [ - {"tool_name": "band_lookup_peers", "arguments": {"page": 1}} - ] - assert json.loads(result) == {"status": "ok"} - - async def test_passes_string_results_through_unchanged(self): - class _StrTools(FakeAgentTools): - async def execute_tool_call(self, tool_name: str, arguments: dict) -> Any: - return "raw-string" - - entry = _make_band_entrypoint("band_lookup_peers") - with _bind_room_tools(_StrTools()): - assert await entry() == "raw-string" - - async def test_errors_outside_any_bound_context(self, tools): - entry = _make_band_entrypoint("band_lookup_peers") - - # Bind then exit; the ContextVar must reset so later calls have no tools. - with _bind_room_tools(tools): - pass - result = await entry(page=1) - - assert "no active Band context" in result - assert tools.tool_calls == [] - - -class TestReply: - """The adapter delivers nothing on its own. Like the other adapters, the - agent must call ``band_send_message`` to reach the room; plain agent text is - never auto-sent and the adapter never guesses a recipient.""" - - async def test_no_send_when_agent_returns_only_text( - self, make_started_adapter, sample_platform_message, tools - ): - adapter, _ = await make_started_adapter(RunOutput(content="hello")) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_no_messages_sent() - - async def test_no_send_when_agent_called_band_send_message( - self, make_started_adapter, sample_platform_message, tools - ): - # The tool call itself reaches the room; the adapter adds nothing on top. - response = RunOutput( - content="hello", tools=[tool_execution("band_send_message")] - ) - adapter, _ = await make_started_adapter(response) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_no_messages_sent() - - async def test_no_send_for_empty_content( - self, make_started_adapter, sample_platform_message, tools - ): - adapter, _ = await make_started_adapter(RunOutput(content=" ")) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_no_messages_sent() - - -class TestEmitExecution: - async def test_emits_tool_call_and_result_events( - self, make_started_adapter, sample_platform_message, tools - ): - # Streamed run: started + completed events for one tool call. Reporting - # is live (during the run), driven off Agno's native stream events. - events = tool_events( - tool_execution("band_lookup_peers", args={"page": "1"}, result="ok") - ) - adapter, _ = await make_started_adapter( - features=AdapterFeatures(emit={Emit.EXECUTION}), events=events - ) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - types = [e["message_type"] for e in tools.events_sent] - assert types == ["tool_call", "tool_result"] - call_payload = json.loads(tools.events_sent[0]["content"]) - result_payload = json.loads(tools.events_sent[1]["content"]) - assert call_payload == { - "name": "band_lookup_peers", - "args": {"page": "1"}, - "tool_call_id": "tc_1", - } - assert result_payload["output"] == "ok" - assert result_payload["is_error"] is False - - async def test_self_reporting_tools_are_not_re_emitted( - self, make_started_adapter, sample_platform_message, tools - ): - events = tool_events(tool_execution("band_send_message")) - adapter, _ = await make_started_adapter( - features=AdapterFeatures(emit={Emit.EXECUTION}), events=events - ) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.events_sent == [] - - async def test_no_events_without_execution_emit( - self, make_started_adapter, sample_platform_message, tools - ): - # No Emit.EXECUTION -> non-streaming run, no live reporting. The final - # RunOutput still carries executions, but nothing is emitted. - response = RunOutput(tools=[tool_execution("band_lookup_peers")]) - adapter, agent = await make_started_adapter(response) # no emit configured - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.events_sent == [] - # Confirms the non-streaming path: a single awaited arun, not a stream. - agent.arun.assert_awaited_once() - - -class TestEmitThoughts: - async def test_emits_reasoning_as_thought( - self, make_started_adapter, sample_platform_message, tools - ): - response = RunOutput(reasoning_content="thinking hard") - adapter, _ = await make_started_adapter( - response, features=AdapterFeatures(emit={Emit.THOUGHTS}) - ) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tools.assert_event_sent(message_type="thought") - assert tools.events_sent[0]["content"] == "thinking hard" - - async def test_no_thought_without_thoughts_emit( - self, make_started_adapter, sample_platform_message, tools - ): - response = RunOutput(reasoning_content="thinking hard") - adapter, _ = await make_started_adapter(response) # no emit configured - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.events_sent == [] - - async def test_no_thought_for_blank_reasoning( - self, make_started_adapter, sample_platform_message, tools - ): - adapter, _ = await make_started_adapter( - RunOutput(reasoning_content=" "), - features=AdapterFeatures(emit={Emit.THOUGHTS}), - ) - - await adapter.on_message( - sample_platform_message, - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.events_sent == [] - - -class TestPersistAndAccumulate: - def test_persist_keeps_only_conversation_roles(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - response = RunOutput( - messages=[ - Message(role="system", content="instructions"), - Message(role="user", content="hi"), - Message(role="assistant", content="hello"), - Message(role="developer", content="state"), - Message(role="tool", content="result"), - ] - ) - - adapter._persist_turn("room-1", response) - - kept = [m.role for m in adapter._message_history["room-1"]] - assert kept == ["user", "assistant", "tool"] - - def test_bootstrap_seeds_committed_transcript_from_history( - self, make_agno_agent, sample_platform_message - ): - # Bootstrap seeds the committed transcript from rehydrated history. The - # returned run input is that seed plus this turn's live message, but - # building it must NOT push the live message into the committed store. - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - seed = [Message(role="user", content="earlier")] - - run_input_msgs = adapter._build_run_input( - sample_platform_message, - seed, - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert [m.content for m in run_input_msgs] == [ - "earlier", - sample_platform_message.format_for_llm(), - ] - # Committed transcript holds only the rehydrated seed. - assert [m.content for m in adapter._message_history["room-1"]] == ["earlier"] - - def test_build_run_input_does_not_mutate_committed_transcript( - self, make_agno_agent, sample_platform_message - ): - # A non-bootstrap turn reads the committed transcript but never writes to - # it; the store is only ever advanced by _persist_turn after a run. - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - adapter._message_history["room-1"] = [Message(role="user", content="committed")] - - adapter._build_run_input( - sample_platform_message, - [], - "participants", - "contacts", - is_session_bootstrap=False, - room_id="room-1", - ) - - assert [m.content for m in adapter._message_history["room-1"]] == ["committed"] - - -class TestFailedRunDoesNotContaminateNextTurn: - async def test_failed_turn_leaves_no_residue_in_next_run_input( - self, make_agno_agent, tools - ): - # Turn 1 raises mid-run; turn 2 succeeds. The injected system/user - # messages from the failed turn must not survive into turn 2's input. - agent = make_agno_agent() - agent.arun = AsyncMock( - side_effect=[RuntimeError("boom"), RunOutput(content="ok")] - ) - adapter = AgnoAdapter(agent) - await adapter.on_started("TestBot", "desc") - - first = _msg("room-1", "first question", msg_id="m1") - with pytest.raises(RuntimeError): - await adapter.on_message( - first, - tools, - [], - "P1-participants", - "C1-contacts", - is_session_bootstrap=True, - room_id="room-1", - ) - - second = _msg("room-1", "second question", msg_id="m2") - await adapter.on_message( - second, - tools, - [], - "P2-participants", - "C2-contacts", - is_session_bootstrap=False, - room_id="room-1", - ) - - contents = [m.content for m in run_input(agent)] - # No residue from the failed turn 1. - assert not any("P1-participants" in c for c in contents) - assert not any("C1-contacts" in c for c in contents) - assert first.format_for_llm() not in contents - # Turn 2's own injected context and live message are present. - assert any("P2-participants" in c for c in contents) - assert contents[-1] == second.format_for_llm() - - -class TestOnCleanup: - async def test_drops_room_transcript(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - adapter._message_history["room-1"] = [Message(role="user", content="hi")] - - await adapter.on_cleanup("room-1") - - assert "room-1" not in adapter._message_history - - async def test_unknown_room_is_noop(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - - await adapter.on_cleanup("never-seen") # must not raise - - -class TestUsedBeforeStarted: - async def test_run_agent_before_on_started_raises(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent) - - with pytest.raises(RuntimeError, match="before on_started"): - await adapter._run_agent( - [], FakeAgentTools(), room_id="room-1", msg_id="m1" - ) - - -class TestSessionIsolation: - async def test_arun_uses_room_id_as_session_id(self, make_started_adapter): - adapter, agent = await make_started_adapter() - - await adapter.on_message( - _msg("room-A", "hi"), - FakeAgentTools(), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - - assert agent.arun.await_args.kwargs["session_id"] == "room-A" - - async def test_custom_session_id_factory_is_used(self, make_agno_agent): - agent = make_agno_agent() - adapter = AgnoAdapter(agent, session_id_factory=lambda room: f"sess::{room}") - await adapter.on_started("TestBot", "desc") - - await adapter.on_message( - _msg("room-A", "hi"), - FakeAgentTools(), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - - assert agent.arun.await_args.kwargs["session_id"] == "sess::room-A" - - async def test_two_rooms_get_isolated_sessions_and_inputs( - self, make_started_adapter - ): - adapter, agent = await make_started_adapter() - - await adapter.on_message( - _msg("room-A", "alpha-secret"), - FakeAgentTools(), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - await adapter.on_message( - _msg("room-B", "beta-secret"), - FakeAgentTools(), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-B", - ) - - calls = agent.arun.await_args_list - assert calls[0].kwargs["session_id"] == "room-A" - assert calls[1].kwargs["session_id"] == "room-B" - - room_b_input = " ".join(m.content or "" for m in calls[1].kwargs["input"]) - assert "beta-secret" in room_b_input - assert "alpha-secret" not in room_b_input - - -class TestHubContactExposure: - """The adapter decides contact exposure (mirrors LangGraph): the CONTACTS - capability OR a hub room force-includes contact tool schemas, resolved per - run so visibility is strictly per-room.""" - - async def test_normal_room_does_not_request_contacts(self, make_started_adapter): - adapter, _ = await make_started_adapter() - tools = SchemaTools([], room_id="room-A") - - with _bind_room_tools(tools): - await adapter._resolve_room_tools() - - assert tools.schema_calls == [ - {"include_memory": False, "include_contacts": False} - ] - - async def test_hub_room_forces_contacts(self, make_started_adapter): - adapter, _ = await make_started_adapter() - tools = SchemaTools([], hub_room_id="hub", room_id="hub") - - with _bind_room_tools(tools): - await adapter._resolve_room_tools() - - assert tools.schema_calls == [ - {"include_memory": False, "include_contacts": True} - ] - - async def test_contacts_do_not_leak_into_normal_room_after_hub( - self, make_started_adapter - ): - # Core regression: after a hub room exposes contact tools, a subsequent - # normal room's resolution must NOT include them. The old additive wiring - # accumulated the union on the shared agent; per-run resolution does not. - adapter, _ = await make_started_adapter() - - hub = ContactAwareTools(hub_room_id="hub", room_id="hub") - with _bind_room_tools(hub): - hub_names = [fn.name for fn in await adapter._resolve_room_tools()] - assert "band_add_contact" in hub_names - - normal = ContactAwareTools(room_id="room-A") - with _bind_room_tools(normal): - normal_names = [fn.name for fn in await adapter._resolve_room_tools()] - - assert normal_names == ["band_send_message"] - assert "band_add_contact" not in normal_names - - -class TestFeatureFilters: - """AdapterFeatures include/exclude/category filters gate which Band tools - are wired (parity with LangGraph).""" - - ALL_SCHEMAS = [ - openai_tool_schema("band_send_message"), # chat - openai_tool_schema("band_lookup_peers"), # chat - openai_tool_schema("band_store_memory"), # memory - openai_tool_schema("band_add_contact"), # contacts - ] - - async def _resolved_names(self, adapter) -> list[str]: - tools = SchemaTools(self.ALL_SCHEMAS) - with _bind_room_tools(tools): - resolved = await adapter._resolve_room_tools() - return [fn.name for fn in resolved] - - async def test_include_tools_keeps_only_named(self, make_started_adapter): - adapter, _ = await make_started_adapter( - features=AdapterFeatures(include_tools=["band_send_message"]) - ) - - assert await self._resolved_names(adapter) == ["band_send_message"] - - async def test_exclude_tools_drops_named(self, make_started_adapter): - adapter, _ = await make_started_adapter( - features=AdapterFeatures(exclude_tools=["band_send_message"]) - ) - - names = await self._resolved_names(adapter) - assert "band_send_message" not in names - assert "band_lookup_peers" in names - - async def test_include_categories_keeps_only_category(self, make_started_adapter): - adapter, _ = await make_started_adapter( - features=AdapterFeatures(include_categories=["chat"]) - ) - - assert sorted(await self._resolved_names(adapter)) == [ - "band_lookup_peers", - "band_send_message", - ] - - -class TestRunFailureReporting: - async def test_emits_generic_error_event_and_reraises( - self, make_started_adapter, tools - ): - adapter, agent = await make_started_adapter() - agent.arun.side_effect = RuntimeError("db dsn leaked: secret-token") - - with pytest.raises(RuntimeError): - await adapter.on_message( - _msg("room-A", "hi"), - tools, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - - errors = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(errors) == 1 - assert ( - errors[0]["content"] - == "Internal error while processing message; see agent logs." - ) - # The exception text (which can carry secrets) must not leak to the room. - assert "secret-token" not in errors[0]["content"] - - async def test_error_event_failure_does_not_mask_original( - self, make_started_adapter - ): - adapter, agent = await make_started_adapter() - agent.arun.side_effect = RuntimeError("boom") - - class _FailingEventTools(FakeAgentTools): - async def send_event(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - raise RuntimeError("event transport down") - - # The failed error-report must not replace the original exception. - with pytest.raises(RuntimeError, match="boom"): - await adapter.on_message( - _msg("room-A", "hi"), - _FailingEventTools(), - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - - -class TestPerRunToolExposureEndToEnd: - """Drive a real Agno agent so we assert on the tools Agno actually offered - the model per run -- proving the factory is installed and invoked per turn, - and that contact tools do not leak across rooms through the shared agent.""" - - async def test_model_receives_only_active_room_tools(self): - model = CapturingModel() - agno = AgnoAgent(model=model, instructions="You are Dev.") - adapter = AgnoAdapter(agno) - await adapter.on_started("Bot", "desc") - - # Hub room: contact tools are offered to the model. - hub = ContactAwareTools(hub_room_id="hub", room_id="hub") - await adapter.on_message( - _msg("hub", "hi"), - hub, - [], - None, - None, - is_session_bootstrap=True, - room_id="hub", - ) - assert model.captured_tool_names is not None - assert "band_add_contact" in model.captured_tool_names - - # Normal room afterwards on the same shared agent: no contact leak. - normal = ContactAwareTools(room_id="room-A") - await adapter.on_message( - _msg("room-A", "hi"), - normal, - [], - None, - None, - is_session_bootstrap=True, - room_id="room-A", - ) - assert model.captured_tool_names == ["band_send_message"] diff --git a/tests/adapters/agno/test_history_guard.py b/tests/adapters/agno/test_history_guard.py deleted file mode 100644 index 4b3894f16..000000000 --- a/tests/adapters/agno/test_history_guard.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Guard for Agno-managed history. - -When the developer's Agno agent persists and replays its own history -(``add_history_to_context=True`` *with* a ``db``), Band must stop rehydrating -its transcript into the run input — otherwise the two history sources collide -and contaminate the context. Band still keeps its per-turn transcript store; it -simply no longer feeds it back into the run. - -These drive the real ``on_event`` -> ``AgnoHistoryConverter`` path and inspect -the exact ``list[Message]`` Agno received via the faked ``agent.arun(input=...)``. -""" - -from __future__ import annotations - -import warnings - -import pytest -from agno.models.message import Message -from agno.run.agent import RunOutput - -from band.adapters.agno import AgnoAdapter -from band.runtime.formatters import format_history_for_llm - -from tests.adapters.agno.helpers import make_agent_input, platform_msg, run_input - - -class TestDetection: - async def test_warns_and_flags_when_db_and_history_enabled(self, make_agno_agent): - agent = make_agno_agent(add_history_to_context=True, db=object()) - adapter = AgnoAdapter(agent) - - # Detection runs against the runtime agent at startup, not in __init__. - with pytest.warns(UserWarning, match="manages its own conversation history"): - await adapter.on_started("TestBot", "desc") - - assert adapter._agno_manages_history is True - - @pytest.mark.parametrize( - ("add_history_to_context", "db"), - [ - (True, None), # history flag but no db -> Agno loads nothing - (False, object()), # db but flag off - (False, None), # neither - ], - ) - async def test_no_guard_unless_both_set( - self, make_agno_agent, add_history_to_context, db - ): - agent = make_agno_agent(add_history_to_context=add_history_to_context, db=db) - adapter = AgnoAdapter(agent) - - with warnings.catch_warnings(): - warnings.simplefilter("error") # any history warning would fail here - await adapter.on_started("TestBot", "desc") - - assert adapter._agno_manages_history is False - - -class TestRehydrationDisabled: - async def test_bootstrap_run_input_omits_rehydrated_history( - self, make_started_adapter, sample_platform_message - ): - raw = format_history_for_llm( - [ - platform_msg("h1", "Prior question", sender_name="Alice"), - platform_msg( - "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" - ), - ], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter( - RunOutput(content="ack"), add_history_to_context=True, db=object() - ) - - await adapter.on_event( - make_agent_input( - sample_platform_message, - raw, - is_session_bootstrap=True, - participants_msg="Alice and Bob are here", - ) - ) - - msgs = run_input(agent) - # Only the participants line and the current message — no rehydrated turns. - assert [m.content for m in msgs] == [ - "[System]: Alice and Bob are here", - sample_platform_message.format_for_llm(), - ] - - async def test_second_turn_does_not_carry_over_band_transcript( - self, make_started_adapter, sample_platform_message - ): - turn = RunOutput( - content="a1", - messages=[ - Message(role="user", content="[Alice]: q1"), - Message(role="assistant", content="a1"), - ], - ) - adapter, agent = await make_started_adapter( - turn, add_history_to_context=True, db=object() - ) - - await adapter.on_event( - make_agent_input(sample_platform_message, [], is_session_bootstrap=True) - ) - await adapter.on_event( - make_agent_input(sample_platform_message, [], is_session_bootstrap=False) - ) - - # The follow-up turn sends only the current message: Agno supplies prior - # turns from its own database, so Band must not replay turn 1. - msgs = run_input(agent) - assert [m.content for m in msgs] == [sample_platform_message.format_for_llm()] - - -class TestStorePreserved: - async def test_transcript_is_still_stored_when_guard_on( - self, make_started_adapter, sample_platform_message - ): - turn = RunOutput( - content="a1", - messages=[ - Message(role="user", content="[Alice]: q1"), - Message(role="assistant", content="a1"), - ], - ) - adapter, _ = await make_started_adapter( - turn, add_history_to_context=True, db=object() - ) - - room_id = sample_platform_message.room_id - await adapter.on_event( - make_agent_input(sample_platform_message, [], is_session_bootstrap=True) - ) - - # "Store the history, just don't rehydrate it": _persist_turn still records - # the transcript even though it is no longer fed back into the run input. - assert adapter._message_history[room_id] == turn.messages diff --git a/tests/adapters/agno/test_history_persistence.py b/tests/adapters/agno/test_history_persistence.py deleted file mode 100644 index f23223d57..000000000 --- a/tests/adapters/agno/test_history_persistence.py +++ /dev/null @@ -1,119 +0,0 @@ -"""End-to-end proof that, when Agno owns history, Agno (not Band) supplies it. - -Unlike the unit tests in ``test_history_guard.py`` (which fake the Agno agent), -this drives a **real** ``AgnoAgent`` backed by a real in-memory database with a -fixed ``session_id``, mocking only the LLM via ``CapturingModel``. We run a turn, -"reset" the agent (a brand-new adapter/agent instance sharing the same db and -session), run another turn, and inspect the exact messages the model received. - -Source attribution relies on two non-overlapping markers. The turn-1 message is -persisted only to Agno's db and is never handed back to Band, so if it reappears -on turn 2 it can only have come from Agno. On turn 2 Band is handed a *distinct* -sentinel history; with the guard on that sentinel must be dropped. So a pass -means Agno supplied prior context and Band's rehydration was suppressed — which -is exactly the behaviour the guard protects. -""" - -from __future__ import annotations - -from datetime import datetime, timezone - -import pytest -from agno.agent import Agent as AgnoAgent -from agno.db.in_memory import InMemoryDb - -from band.adapters.agno import AgnoAdapter -from band.core.types import PlatformMessage -from band.runtime.formatters import format_history_for_llm - -from tests.adapters.agno.helpers import ( - CapturingModel, - SchemaTools, - make_agent_input, - platform_msg, -) - -BAND_SENTINEL = "BAND-REHYDRATED-SENTINEL" - -ROOM_ID = "room-roundtrip" - - -def _platform_message(msg_id: str, content: str) -> PlatformMessage: - return PlatformMessage( - id=msg_id, - room_id=ROOM_ID, - content=content, - sender_id="user-1", - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(timezone.utc), - ) - - -def _captured(adapter: AgnoAdapter) -> CapturingModel: - agent = adapter.agent - assert agent is not None - model = agent.model - assert isinstance(model, CapturingModel) - return model - - -async def test_history_survives_restart_and_is_loaded_by_agno_not_band(): - db = InMemoryDb() - - def build_agent(reply: str) -> AgnoAgent: - # Same db + session_id across instances models a persistent backend that - # outlives a single agent process. - return AgnoAgent( - model=CapturingModel(reply), - db=db, - session_id=ROOM_ID, - add_history_to_context=True, - instructions="You are Bot.", - ) - - # Startup warns that Band rehydration is disabled, and flags the guard. - adapter = AgnoAdapter(build_agent("first answer")) - with pytest.warns(UserWarning, match="manages its own conversation history"): - await adapter.on_started("Bot", "desc") - assert adapter._agno_manages_history is True - - # Turn 1 — Band supplies NO history (raw=[]); only the live message is sent. - first = _platform_message("m1", "remember the code is 42") - await adapter.on_event( - make_agent_input(first, [], is_session_bootstrap=True, tools=SchemaTools([])) - ) - assert not any(m.from_history for m in _captured(adapter).captured_messages or []) - - # "Reset": a brand-new adapter/agent instance pointed at the same db+session. - adapter2 = AgnoAdapter(build_agent("second answer")) - with pytest.warns(UserWarning, match="manages its own conversation history"): - await adapter2.on_started("Bot", "desc") - - # Turn 2 — hand Band a DISTINCT platform history. With the guard on it must - # be ignored; only Agno's own db history should reach the model. - second = _platform_message("m2", "what was the code?") - band_raw = format_history_for_llm( - [platform_msg("hX", BAND_SENTINEL, sender_name="Ghost")], - exclude_id=second.id, - ) - await adapter2.on_event( - make_agent_input( - second, band_raw, is_session_bootstrap=True, tools=SchemaTools([]) - ) - ) - - captured = _captured(adapter2).captured_messages or [] - # Band's rehydration is suppressed: its sentinel never reaches the model. - assert not any(BAND_SENTINEL in (m.content or "") for m in captured) - - users = [m for m in captured if m.role == "user"] - # The prior turn reappears tagged from_history -> loaded by Agno's db, not by - # Band (whose sentinel above was dropped). - rehydrated = [m for m in users if m.from_history] - assert any(m.content == first.format_for_llm() for m in rehydrated) - # The live message is the last user turn and is NOT history. - assert users[-1].content == second.format_for_llm() - assert users[-1].from_history is False diff --git a/tests/adapters/agno/test_rehydration.py b/tests/adapters/agno/test_rehydration.py deleted file mode 100644 index 1b4ba0948..000000000 --- a/tests/adapters/agno/test_rehydration.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Agno history/context rehydration tests. - -These drive the adapter through the real ``on_event`` path so the real -``AgnoHistoryConverter`` runs, then inspect the exact ``list[Message]`` Agno -received via the faked ``agent.arun(input=...)``. Assertions are on real Agno -``Message`` objects (roles, ``tool_calls``, ``tool_call_id``, ``from_history``), -never on hardcoded prose. History is built with the real runtime formatter -``format_history_for_llm`` rather than hand-rolled converter-ready dicts. -""" - -from __future__ import annotations - -from agno.models.message import Message -from agno.run.agent import RunOutput - -from band.runtime.formatters import format_history_for_llm -from tests.framework_configs.fixtures import TOOL_CALL_SEARCH, TOOL_RESULT_SEARCH - -from tests.adapters.agno.helpers import make_agent_input, platform_msg, run_input - - -class TestRehydrationPipeline: - """Drive on_event so the real AgnoHistoryConverter runs, then inspect the - actual run input Agno received.""" - - async def test_all_message_kinds_become_the_right_messages( - self, make_started_adapter, sample_platform_message - ): - # Authentic rehydration: build platform dicts and run them through the - # real runtime formatter (which also drops the current message). - raw = format_history_for_llm( - [ - platform_msg("h1", "Prior question", sender_name="Alice"), - platform_msg( - "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" - ), - platform_msg( - "h3", - TOOL_CALL_SEARCH["content"], - sender_type="Agent", - sender_name="TestBot", - message_type="tool_call", - ), - platform_msg( - "h4", - TOOL_RESULT_SEARCH["content"], - sender_type="Agent", - sender_name="TestBot", - message_type="tool_result", - ), - ], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter(RunOutput(content="ack")) - - await adapter.on_event( - make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) - ) - - msgs = run_input(agent) - assert [m.role for m in msgs] == [ - "user", # other participant text - "assistant", # own-agent text - "assistant", # tool_call batched onto an assistant message - "tool", # tool_result - "user", # the current (live) message - ] - assert msgs[0].content == "[Alice]: Prior question" - assert msgs[1].content == "Earlier answer" - assert msgs[2].tool_calls[0]["function"]["name"] == "search" - assert msgs[3].tool_call_id == "tc_1" - assert msgs[-1].content == sample_platform_message.format_for_llm() - - async def test_unsupported_kinds_are_dropped( - self, make_started_adapter, sample_platform_message - ): - raw = format_history_for_llm( - [ - platform_msg("h1", "hello", sender_name="Alice"), - platform_msg( - "h2", - "thinking out loud", - sender_type="Agent", - sender_name="TestBot", - message_type="thought", - ), - platform_msg("h3", "weird", message_type="mystery"), - ], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter(RunOutput(content="ack")) - - await adapter.on_event( - make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) - ) - - msgs = run_input(agent) - # Only the plain text + current message survive; thought/unknown dropped. - assert [m.content for m in msgs] == [ - "[Alice]: hello", - sample_platform_message.format_for_llm(), - ] - - async def test_history_is_from_history_but_current_message_is_live( - self, make_started_adapter, sample_platform_message - ): - raw = format_history_for_llm( - [platform_msg("h1", "hi", sender_name="Alice")], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter(RunOutput(content="ack")) - - await adapter.on_event( - make_agent_input(sample_platform_message, raw, is_session_bootstrap=True) - ) - - msgs = run_input(agent) - assert all(m.from_history for m in msgs[:-1]) # rehydrated context - assert not msgs[-1].from_history # the message to actually answer - - async def test_participants_and_contacts_injected_before_current_message( - self, make_started_adapter, sample_platform_message - ): - adapter, agent = await make_started_adapter(RunOutput(content="ok")) - - await adapter.on_event( - make_agent_input( - sample_platform_message, - [], - is_session_bootstrap=True, - participants_msg="Alice and Bob are here", - contacts_msg="Carol is now a contact", - ) - ) - - msgs = run_input(agent) - assert [m.content for m in msgs] == [ - "[System]: Alice and Bob are here", - "[System]: Carol is now a contact", - sample_platform_message.format_for_llm(), - ] - - -class TestUnansweredMessage: - async def test_current_message_excluded_from_history_then_answered( - self, make_started_adapter, sample_platform_message, tools - ): - current = sample_platform_message - # The platform context includes the current message; the formatter must - # exclude it so it is answered, not replayed as context. - raw = format_history_for_llm( - [ - platform_msg("h1", "previous", sender_name="Alice"), - {**platform_msg(current.id, current.content), "id": current.id}, - ], - exclude_id=current.id, - ) - assert len(raw) == 1 - assert all(current.content not in h["content"] for h in raw) - - adapter, agent = await make_started_adapter( - RunOutput(content="here is your answer") - ) - - await adapter.on_event( - make_agent_input(current, raw, is_session_bootstrap=True, tools=tools) - ) - - msgs = run_input(agent) - formatted = current.format_for_llm() - assert sum(1 for m in msgs if m.content == formatted) == 1 - assert msgs[-1].content == formatted - - async def test_answers_unanswered_message_on_restart_bootstrap( - self, make_started_adapter, sample_platform_message, tools - ): - # Agent restarts: first event is bootstrap, with a completed exchange in - # history and a brand-new unanswered question as the current message. - raw = format_history_for_llm( - [ - platform_msg("h1", "Earlier question", sender_name="Alice"), - platform_msg( - "h2", "Earlier answer", sender_type="Agent", sender_name="TestBot" - ), - ], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter(RunOutput(content="fresh answer")) - - await adapter.on_event( - make_agent_input( - sample_platform_message, raw, is_session_bootstrap=True, tools=tools - ) - ) - - agent.arun.assert_awaited_once() - assert run_input(agent)[-1].content == sample_platform_message.format_for_llm() - - async def test_trailing_unanswered_user_turns_are_preserved( - self, make_started_adapter, sample_platform_message, tools - ): - # Several user turns with no assistant reply between them: agno keeps them - # all as user messages (it does not require complete exchanges). - raw = format_history_for_llm( - [ - platform_msg("h1", "first", sender_name="Alice"), - platform_msg("h2", "second", sender_name="Bob"), - platform_msg("h3", "third", sender_name="Alice"), - ], - exclude_id=sample_platform_message.id, - ) - adapter, agent = await make_started_adapter(RunOutput(content="answering all")) - - await adapter.on_event( - make_agent_input( - sample_platform_message, raw, is_session_bootstrap=True, tools=tools - ) - ) - - msgs = run_input(agent) - assert [m.role for m in msgs] == ["user", "user", "user", "user"] - assert [m.content for m in msgs[:3]] == [ - "[Alice]: first", - "[Bob]: second", - "[Alice]: third", - ] - - -class TestMultiTurnCarryover: - async def test_persisted_transcript_feeds_the_next_turn( - self, make_started_adapter, sample_platform_message - ): - # Turn 1's run produces a transcript; _persist_turn keeps it and the next - # turn must build on top of it (carryover through the real on_message path). - turn = RunOutput( - content="a1", - messages=[ - Message(role="user", content="[Alice]: q1"), - Message(role="assistant", content="a1"), - ], - ) - adapter, agent = await make_started_adapter(turn) - - await adapter.on_event( - make_agent_input(sample_platform_message, [], is_session_bootstrap=True) - ) - await adapter.on_event( - make_agent_input(sample_platform_message, [], is_session_bootstrap=False) - ) - - msgs = run_input(agent) # the second (follow-up) turn's input - assert [m.content for m in msgs[:2]] == ["[Alice]: q1", "a1"] - assert msgs[-1].content == sample_platform_message.format_for_llm() - assert len(msgs) == 3 diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index 17dfb761c..9a3957305 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -232,47 +232,6 @@ async def test_retries_transient_server_errors(self): assert response.candidates[0].content.parts[0].text == "ok" -class TestBuildGeminiTools: - """Gemini rejects numeric bounds and additionalProperties on tool params, so - the adapter must sanitize Band schemas before building declarations.""" - - def test_declarations_drop_numeric_bounds_and_additional_properties( - self, mock_tools - ): - mock_tools.get_openai_tool_schemas = MagicMock( - return_value=[ - { - "type": "function", - "function": { - "name": "band_lookup_peers", - "description": "lookup peers", - "parameters": { - "type": "object", - "properties": { - "page_size": { - "type": "integer", - "minimum": 1, - "maximum": 100, - }, - }, - "additionalProperties": False, - }, - }, - } - ] - ) - adapter = GeminiAdapter(provider_key="test-key") - - tools = adapter._build_gemini_tools(mock_tools) - - decl = tools[0].function_declarations[0] - schema = decl.parameters_json_schema - assert "additionalProperties" not in schema - page_size = schema["properties"]["page_size"] - assert "minimum" not in page_size - assert "maximum" not in page_size - - class TestCustomTools: @pytest.mark.asyncio async def test_executes_custom_tool(self, mock_tools): diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 6ebcbb7f0..21495e03c 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -26,6 +26,7 @@ _get_tool_bridge_class = _google_adk_mod._get_tool_bridge_class _BandToolBridge = _get_tool_bridge_class() _sanitize_adk_agent_name = _google_adk_mod._sanitize_adk_agent_name +_strip_additional_properties = _google_adk_mod._strip_additional_properties @pytest.fixture @@ -525,6 +526,58 @@ def test_smoke_test_runs_at_class_creation(self): assert decl.name == "smoke" +class TestStripAdditionalProperties: + """Tests for _strip_additional_properties module-level function.""" + + def test_strips_additional_properties(self): + """Should strip additionalProperties from schema for Gemini compatibility.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "nested": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + "required": ["name"], + } + + cleaned = _strip_additional_properties(schema) + + assert "additionalProperties" not in cleaned + assert "additionalProperties" not in cleaned["properties"]["nested"] + assert cleaned["properties"]["name"] == {"type": "string"} + assert cleaned["required"] == ["name"] + + def test_handles_top_level_list(self): + """Should recurse into top-level list items (e.g. anyOf/oneOf schemas).""" + schema_list = [ + {"type": "string", "additionalProperties": False}, + { + "type": "object", + "properties": {"x": {"type": "integer"}}, + "additionalProperties": False, + }, + ] + + cleaned = _strip_additional_properties(schema_list) + + assert isinstance(cleaned, list) + assert len(cleaned) == 2 + assert "additionalProperties" not in cleaned[0] + assert "additionalProperties" not in cleaned[1] + assert cleaned[1]["properties"]["x"] == {"type": "integer"} + + def test_handles_non_dict_input(self): + """Should return non-dict/non-list input as-is.""" + assert _strip_additional_properties("string") == "string" + assert _strip_additional_properties(42) == 42 + assert _strip_additional_properties(None) is None + + class TestBuildADKTools: """Tests for _build_adk_tools.""" diff --git a/tests/converters/test_agno.py b/tests/converters/test_agno.py deleted file mode 100644 index 4f6aa6bce..000000000 --- a/tests/converters/test_agno.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Agno-specific history converter tests. - -These cover behavior the framework-conformance suite cannot assert because it -only checks generic shape via an output adapter ("tool name appears somewhere", -text/own-message handling). Here we assert on the real Agno ``Message`` objects: -tool_call/tool_result structure, batching, role mapping, and the ``from_history`` -tagging that stops Agno from re-adding stored session history. -""" - -from __future__ import annotations - -import json - -from band.converters.agno import AgnoHistoryConverter -from tests.framework_configs.fixtures import ( - TOOL_CALL_LOOKUP, - TOOL_CALL_SEARCH, - TOOL_CALL_SEARCH_EMPTY, - TOOL_RESULT_SEARCH, -) - - -def _text(content: str, *, role: str = "user", sender_name: str = "") -> dict: - return { - "role": role, - "content": content, - "sender_name": sender_name, - "message_type": "text", - } - - -class TestToolCallShape: - def test_tool_call_becomes_assistant_message_with_function_dict(self): - result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) - - assert len(result) == 1 - msg = result[0] - assert msg.role == "assistant" - assert msg.content is None - assert msg.from_history is True - assert msg.tool_calls == [ - { - "id": "tc_1", - "type": "function", - "function": { - "name": "search", - "arguments": json.dumps({"query": "test"}), - }, - } - ] - - def test_arguments_are_json_string_not_dict(self): - result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) - - arguments = result[0].tool_calls[0]["function"]["arguments"] - assert isinstance(arguments, str) - assert json.loads(arguments) == {"query": "test"} - - -class TestToolResultShape: - def test_tool_result_becomes_tool_role_message(self): - result = AgnoHistoryConverter().convert( - [dict(TOOL_CALL_SEARCH), dict(TOOL_RESULT_SEARCH)] - ) - - assert len(result) == 2 - tool_msg = result[1] - assert tool_msg.role == "tool" - assert tool_msg.tool_call_id == "tc_1" - assert tool_msg.tool_name == "search" - assert tool_msg.content == "result data" - assert tool_msg.tool_call_error is False - assert tool_msg.from_history is True - - def test_error_flag_maps_to_tool_call_error(self): - errored = { - "role": "assistant", - "content": json.dumps( - { - "name": "search", - "output": "boom", - "tool_call_id": "tc_1", - "is_error": True, - } - ), - "message_type": "tool_result", - } - - result = AgnoHistoryConverter().convert([errored]) - - assert result[0].tool_call_error is True - - -class TestBatchingAndFlush: - def test_consecutive_tool_calls_batch_into_one_message(self): - result = AgnoHistoryConverter().convert( - [dict(TOOL_CALL_SEARCH), dict(TOOL_CALL_LOOKUP)] - ) - - assert len(result) == 1 - assert len(result[0].tool_calls) == 2 - assert [tc["function"]["name"] for tc in result[0].tool_calls] == [ - "search", - "lookup", - ] - - def test_text_flushes_pending_calls_before_appending(self): - result = AgnoHistoryConverter().convert( - [dict(TOOL_CALL_SEARCH), _text("done", sender_name="Alice")] - ) - - assert [m.role for m in result] == ["assistant", "user"] - assert result[0].tool_calls[0]["function"]["name"] == "search" - assert result[1].content == "[Alice]: done" - - def test_orphaned_trailing_tool_calls_are_flushed(self): - result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH)]) - - # No matching tool_result, but the pending call still lands as a message. - assert len(result) == 1 - assert result[0].role == "assistant" - - -class TestTextRoleMapping: - def test_own_agent_text_kept_as_assistant(self): - converter = AgnoHistoryConverter(agent_name="TestBot") - - result = converter.convert( - [_text("on it", role="assistant", sender_name="TestBot")] - ) - - assert len(result) == 1 - assert result[0].role == "assistant" - assert result[0].content == "on it" - assert result[0].from_history is True - - def test_other_sender_gets_user_role_with_prefix(self): - converter = AgnoHistoryConverter(agent_name="TestBot") - - result = converter.convert([_text("hi", sender_name="Alice")]) - - assert result[0].role == "user" - assert result[0].content == "[Alice]: hi" - - def test_missing_sender_name_has_no_prefix(self): - result = AgnoHistoryConverter().convert([_text("hi")]) - - assert result[0].content == "hi" - - -class TestFromHistoryInvariant: - def test_every_message_is_tagged_from_history(self): - converter = AgnoHistoryConverter(agent_name="TestBot") - - result = converter.convert( - [ - _text("hi", sender_name="Alice"), - dict(TOOL_CALL_SEARCH), - dict(TOOL_RESULT_SEARCH), - _text("done", role="assistant", sender_name="TestBot"), - ] - ) - - assert result # sanity: not empty - assert all(m.from_history for m in result) - - -class TestMalformedAndUnknown: - def test_tool_call_missing_id_is_skipped(self): - result = AgnoHistoryConverter().convert([dict(TOOL_CALL_SEARCH_EMPTY)]) - - # TOOL_CALL_SEARCH_EMPTY still has a tool_call_id, so it converts; a - # genuinely id-less call is dropped: - idless = { - "role": "assistant", - "content": json.dumps({"name": "search", "args": {}}), - "message_type": "tool_call", - } - assert AgnoHistoryConverter().convert([idless]) == [] - assert len(result) == 1 # empty args still produce a valid call - - def test_unknown_message_type_is_skipped(self): - thought = {"role": "assistant", "content": "hmm", "message_type": "thought"} - - assert AgnoHistoryConverter().convert([thought]) == [] - - def test_empty_history(self): - assert AgnoHistoryConverter().convert([]) == [] diff --git a/tests/core/test_tool_filter.py b/tests/core/test_tool_filter.py index 71a606aee..504a11470 100644 --- a/tests/core/test_tool_filter.py +++ b/tests/core/test_tool_filter.py @@ -1,4 +1,4 @@ -"""Tests for filter_tool_schemas and sanitize_tool_schema helpers.""" +"""Tests for filter_tool_schemas helper.""" from __future__ import annotations @@ -7,7 +7,7 @@ import pytest -from band.core.tool_filter import filter_tool_schemas, sanitize_tool_schema +from band.core.tool_filter import filter_tool_schemas from band.core.types import AdapterFeatures @@ -110,92 +110,3 @@ def test_category_then_include_precedence_yields_empty(self) -> None: ) # band_store_memory is category "memory", excluded by categories step assert result == [] - - -class TestSanitizeToolSchema: - """sanitize_tool_schema drops provider-incompatible JSON-Schema keywords.""" - - def test_drops_numeric_bounds_recursively(self): - schema = { - "type": "object", - "properties": { - "page": {"type": "integer", "minimum": 1}, - "page_size": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "multipleOf": 1, - }, - "name": {"type": "string"}, - }, - } - - cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) - - assert cleaned["properties"]["page"] == {"type": "integer"} - assert cleaned["properties"]["page_size"] == {"type": "integer"} - assert cleaned["properties"]["name"] == {"type": "string"} - - def test_drops_additional_properties_recursively(self): - schema = { - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": {"x": {"type": "integer"}}, - "additionalProperties": False, - }, - }, - "additionalProperties": False, - } - - cleaned = sanitize_tool_schema(schema, drop_additional_properties=True) - - assert "additionalProperties" not in cleaned - assert "additionalProperties" not in cleaned["properties"]["nested"] - - def test_default_is_a_noop_copy(self): - schema = {"type": "integer", "maximum": 100, "additionalProperties": False} - - cleaned = sanitize_tool_schema(schema) - - assert cleaned == schema - assert cleaned is not schema - - def test_does_not_mutate_input(self): - schema = {"type": "integer", "minimum": 1, "maximum": 100} - - sanitize_tool_schema(schema, drop_numeric_bounds=True) - - assert schema == {"type": "integer", "minimum": 1, "maximum": 100} - - def test_preserves_property_literally_named_maximum(self): - # ``maximum`` as a property *name* (under ``properties``) is a field, not - # the numeric-bound keyword, and must survive the strip. - schema = { - "type": "object", - "properties": { - "maximum": {"type": "integer", "maximum": 10}, - "minimum": {"type": "string"}, - }, - } - - cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) - - assert set(cleaned["properties"]) == {"maximum", "minimum"} - # The bound keyword *inside* the "maximum" field's subschema is stripped. - assert cleaned["properties"]["maximum"] == {"type": "integer"} - - def test_recurses_into_lists_and_returns_non_dicts_as_is(self): - schema = { - "anyOf": [ - {"type": "integer", "maximum": 5}, - {"type": "string"}, - ], - } - - cleaned = sanitize_tool_schema(schema, drop_numeric_bounds=True) - - assert cleaned == {"anyOf": [{"type": "integer"}, {"type": "string"}]} - assert sanitize_tool_schema("x", drop_numeric_bounds=True) == "x" - assert sanitize_tool_schema(None) is None diff --git a/tests/e2e/adapters/conftest.py b/tests/e2e/adapters/conftest.py index e7d237727..c17f15f76 100644 --- a/tests/e2e/adapters/conftest.py +++ b/tests/e2e/adapters/conftest.py @@ -19,7 +19,7 @@ from band.core.simple_adapter import SimpleAdapter -from tests.e2e.settings import E2ESettings +from tests.e2e.conftest import E2ESettings logger = logging.getLogger(__name__) @@ -106,34 +106,6 @@ def create_crewai_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: ) -def create_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Create an Agno adapter for the cross-adapter E2E suite. - - Use a strong instruction-following model via ``E2E_ANTHROPIC_MODEL`` - (e.g. ``claude-sonnet-4-6``). Cheap/small models (e.g. Haiku) refuse the - suite's crafted trigger prompts as prompt-injection; Sonnet 4.6 clears - ``test_tool_execution_send_message[agno]`` (echo a code word) and - ``test_agents_in_different_rooms_isolated[agno]``. - - Note: the room-isolation trigger prompts are framed as a neutral "note" - rather than a "secret code". A "secret code → recall it" prompt reads as a - credential/embedded directive and gets refused even by Sonnet 4.6, which is - unrelated to isolation; the neutral wording avoids that false failure. See - ``tests/e2e/scenarios/test_room_isolation.py``. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - instructions="Keep responses short and concise.", - ) - return AgnoAdapter(agno_agent) - - # ============================================================================= # Adapter Registry # ============================================================================= @@ -144,7 +116,6 @@ def create_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: "pydantic_ai": create_pydantic_ai_adapter, "claude_sdk": create_claude_sdk_adapter, "crewai": create_crewai_adapter, - "agno": create_agno_adapter, } # Note: Parlant is excluded from the default parametrized set because it diff --git a/tests/e2e/adapters/test_agno_memory.py b/tests/e2e/adapters/test_agno_memory.py deleted file mode 100644 index 87623f687..000000000 --- a/tests/e2e/adapters/test_agno_memory.py +++ /dev/null @@ -1,179 +0,0 @@ -"""E2E test for Agno memory tool usage at organization and subject scope. - -A generic "secretary" agent is given Band memory tools (``Capability.MEMORY``) -but its developer instructions never mention scope/system/type/segment. Correct -behavior therefore depends on the injected ``MEMORY_SECTION`` guidance, so a -passing test validates both the memory tools and the prompt-injection feature -(the Agno adapter appends ``MEMORY_SECTION`` to the agent's system prompt when -the memory capability is enabled). - -Memory plumbing (unique markers, polling, teardown cleanup) comes from the shared -``memory`` fixture (``MemoryProbe``); the trigger-and-wait flow from -``send_and_wait_for_reply``. New memory tests should reuse those rather than -re-implementing them. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest tests/e2e/adapters/test_agno_memory.py -v -s --no-cov -""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator - -import pytest -from band_rest import AsyncRestClient - -from band import Agent -from band.core.types import AdapterFeatures, Capability, Emit -from tests.e2e.settings import ( - E2ESettings, - RoomAllocator, - requires_e2e, - requires_openai, -) -from tests.e2e.helpers import ( - MemoryProbe, - TrackingWebSocketClient, - running_agent, - send_and_wait_for_reply, -) - -# Deliberately generic — no mention of scope/system/type/segment, so the agent -# must rely on the injected MEMORY_SECTION guidance to store memories correctly. -SECRETARY_INSTRUCTIONS = ( - "You are a personal secretary who helps the user remember facts for the long " - "run. Whenever the user shares something worth remembering, remember it " - "so you can recall it in future conversations, then briefly " - "confirm. Keep responses short." -) - - -@pytest.fixture -async def agno_memory_room( - e2e_fresh_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - # A fresh room per test: the memory agent must not inherit unrelated history - # from reused rooms, which derails small models and pollutes the scope check. - return await e2e_fresh_room_allocator("agno-memory") - - -@pytest.fixture -async def running_agno_memory_agent( - e2e_config: E2ESettings, -) -> AsyncGenerator[Agent, None]: - """Run an Agno secretary agent with Band memory tools enabled. - - Uses ``running_agent`` so the connect is retried with a cooldown when the - platform rate-limits a rapid reconnect after a recent supersede (HTTP 429), - which happens when both tests in this module run against one agent_id. - """ - from agno.agent import Agent as AgnoAgent - from agno.models.openai import OpenAIChat - - from band.adapters.agno import AgnoAdapter - - agno_agent = AgnoAgent( - model=OpenAIChat(id=e2e_config.e2e_llm_model), - instructions=SECRETARY_INSTRUCTIONS, - ) - # Emit.EXECUTION posts the agent's tool_call/tool_result events to the room, - # so a failing run can be debugged by inspecting what the agent actually did - # (e.g. via band's REST context) instead of guessing. - adapter = AgnoAdapter( - agno_agent, - features=AdapterFeatures( - capabilities={Capability.MEMORY}, - emit={Emit.EXECUTION}, - ), - ) - - async with running_agent( - adapter, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ) as agent: - yield agent - - -# loop_scope="session" runs the agent's background task on the test's event loop -# so it processes the trigger concurrently with the test body. -@pytest.mark.asyncio(loop_scope="session") -@pytest.mark.flaky(reruns=2) -@requires_e2e -@requires_openai -async def test_agno_secretary_stores_organization_memory( - e2e_config: E2ESettings, - agno_memory_room: tuple[str, str, str], - e2e_agent_info: tuple[str, str], - e2e_user_client: AsyncRestClient, - running_agno_memory_agent: Agent, - ws_client: TrackingWebSocketClient, - memory: MemoryProbe, -) -> None: - """A shared/company fact is stored as an organization-scoped memory.""" - chat_id, _user_id, _user_name = agno_memory_room - agent_id, agent_name = e2e_agent_info - marker = memory.marker("Q3LAUNCH") - prompt = ( - "Remember this for the whole organization (so it can be shared " - f"everywhere): the code name for our Q3 launch is {marker}." - ) - - await send_and_wait_for_reply( - ws_client, - e2e_user_client, - chat_id, - prompt, - agent_name, - agent_id, - timeout=e2e_config.e2e_timeout, - ) - - await memory.wait(marker, scope="organization") - - -@pytest.mark.asyncio(loop_scope="session") -@pytest.mark.flaky(reruns=2) -@requires_e2e -@requires_openai -async def test_agno_secretary_stores_subject_memory( - e2e_config: E2ESettings, - agno_memory_room: tuple[str, str, str], - e2e_agent_info: tuple[str, str], - e2e_user_client: AsyncRestClient, - running_agno_memory_agent: Agent, - ws_client: TrackingWebSocketClient, - memory: MemoryProbe, -) -> None: - """A personal fact is stored as a subject-scoped memory linked to the user. - - The agent is only told the fact is "about me specifically" — it must infer - subject scope and resolve the user's subject_id (via band_get_participants / - band_lookup_peers) from the injected memory-scope guidance. - """ - chat_id, user_id, _user_name = agno_memory_room - agent_id, agent_name = e2e_agent_info - marker = memory.marker("BADGE") - prompt = ( - "Remember this about me personally so you recall it whenever we talk: " - f"my employee badge number is {marker}. Save it as being about me " - "specifically." - ) - - await send_and_wait_for_reply( - ws_client, - e2e_user_client, - chat_id, - prompt, - agent_name, - agent_id, - timeout=e2e_config.e2e_timeout, - ) - - matches = await memory.wait(marker, scope="subject", subject_id=user_id) - assert any(getattr(m, "subject_id", None) == user_id for m in matches), ( - f"Expected a subject memory containing {marker} linked to subject " - f"{user_id}, but matched subjects were " - f"{[getattr(m, 'subject_id', None) for m in matches]}." - ) diff --git a/tests/e2e/adapters/test_all_adapters.py b/tests/e2e/adapters/test_all_adapters.py index 73d27ac3a..689824d62 100644 --- a/tests/e2e/adapters/test_all_adapters.py +++ b/tests/e2e/adapters/test_all_adapters.py @@ -4,7 +4,7 @@ - Start, process a message, and stop against a real platform - Execute platform tools (send_message) -Adapters tested: langgraph, anthropic, pydantic_ai, claude_sdk, crewai, agno. +Adapters tested: langgraph, anthropic, pydantic_ai, claude_sdk, crewai. Parlant is excluded (requires separate server setup, see test_parlant.py). Run with: @@ -24,7 +24,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.conftest import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, run_smoke_test, diff --git a/tests/e2e/adapters/test_langgraph_memory.py b/tests/e2e/adapters/test_langgraph_memory.py index e9ff2dfc6..80543082e 100644 --- a/tests/e2e/adapters/test_langgraph_memory.py +++ b/tests/e2e/adapters/test_langgraph_memory.py @@ -6,7 +6,9 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncGenerator, Awaitable, Callable +from uuid import uuid4 import pytest from band_rest import AsyncRestClient @@ -14,11 +16,11 @@ from band import Agent from band.adapters.langgraph import LangGraphAdapter from band.core.types import AdapterFeatures, Capability -from tests.e2e.settings import E2ESettings, requires_e2e, requires_openai +from tests.e2e.conftest import E2ESettings, requires_e2e, requires_openai from tests.e2e.helpers import ( - MemoryProbe, TrackingWebSocketClient, - send_and_wait_for_reply, + listening_for_agent_responses, + send_trigger_message, ) RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] @@ -31,11 +33,9 @@ @pytest.fixture async def langgraph_memory_room( - e2e_fresh_room_allocator: RoomAllocator, + e2e_room_allocator: RoomAllocator, ) -> tuple[str, str, str]: - # A fresh room per test: the memory agent must not inherit unrelated history - # from reused rooms, which derails the model and can stall its reply. - return await e2e_fresh_room_allocator("langgraph-memory") + return await e2e_room_allocator("langgraph-memory") @pytest.fixture @@ -65,6 +65,31 @@ async def running_langgraph_memory_agent( yield agent +async def _wait_for_org_memory_containing( + client: AsyncRestClient, + marker: str, + *, + timeout: float, +) -> None: + deadline = asyncio.get_running_loop().time() + timeout + + while asyncio.get_running_loop().time() < deadline: + response = await client.agent_api_memories.list_agent_memories( + page_size=50, + status="active", + scope="organization", + ) + if any( + marker in (getattr(memory, "content", None) or "") + for memory in response.data or [] + ): + return + + await asyncio.sleep(1) + + pytest.fail(f"Expected organization memory containing {marker}") + + # loop_scope="session" pins the test to the same event loop as the agent's # background processing task, so the agent processes the trigger concurrently with # the test body. A bare @pytest.mark.asyncio would run the body on a separate loop @@ -77,29 +102,35 @@ async def test_langgraph_agent_stores_durable_user_memory( e2e_config: E2ESettings, langgraph_memory_room: tuple[str, str, str], e2e_agent_info: tuple[str, str], + e2e_session_client: AsyncRestClient, e2e_user_client: AsyncRestClient, running_langgraph_memory_agent: Agent, ws_client: TrackingWebSocketClient, - memory: MemoryProbe, ) -> None: """Ask LangGraph to remember a durable preference and verify it is stored.""" chat_id, _user_id, _user_name = langgraph_memory_room agent_id, agent_name = e2e_agent_info - marker = memory.marker("LGMEM") + marker = f"LANGGRAPH_MEMORY_E2E_{uuid4().hex}" prompt = ( - "Remember this for the whole organization so anyone can recall it: the " - f"project code phrase {marker} means we keep responses concise. " - "Acknowledge it briefly." + "Remember this durable preference exactly: " + f"{marker} means I prefer concise memory test responses. " + "Store it as a long-term semantic user memory, then acknowledge it briefly." ) - await send_and_wait_for_reply( - ws_client, - e2e_user_client, - chat_id, - prompt, - agent_name, - agent_id, + async with listening_for_agent_responses( + ws_client, chat_id, timeout=e2e_config.e2e_timeout, raise_on_timeout=True + ) as wait_for_reply: + await send_trigger_message( + e2e_user_client, + chat_id, + prompt, + agent_name, + agent_id, + ) + await wait_for_reply() + + await _wait_for_org_memory_containing( + e2e_session_client, + marker, timeout=e2e_config.e2e_timeout, ) - - await memory.wait(marker, scope="organization") diff --git a/tests/e2e/adapters/test_parlant.py b/tests/e2e/adapters/test_parlant.py index 7df71d715..7d6a80543 100644 --- a/tests/e2e/adapters/test_parlant.py +++ b/tests/e2e/adapters/test_parlant.py @@ -20,7 +20,7 @@ from band.agent import Agent -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.conftest import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, run_smoke_test, diff --git a/tests/e2e/agentcore/test_three_agent_orchestration.py b/tests/e2e/agentcore/test_three_agent_orchestration.py index d4a2b6ba9..9e5674e50 100644 --- a/tests/e2e/agentcore/test_three_agent_orchestration.py +++ b/tests/e2e/agentcore/test_three_agent_orchestration.py @@ -36,7 +36,7 @@ from band_rest import AsyncRestClient, CreateMyChatRoomRequestChat from band_rest.types import ParticipantRequest -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.conftest import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, listening_for_agent_responses, diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 233bee958..d6b2e8fdf 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,52 +1,48 @@ -"""E2E test collection hook and fixture registration. +"""E2E test configuration and fixtures. E2E tests run adapters against a real Band platform with real (cheap) LLMs. -They verify platform functionality and integration correctness, not LLM output -quality. +They verify platform functionality and integration correctness, not LLM output quality. Run manually only, never in CI/CD: E2E_TESTS_ENABLED=true uv run pytest tests/e2e/ -v -s --no-cov -Shared settings, skip markers, and types live in ``tests.e2e.settings`` (a plain -module) so fixtures, helpers, and tests import them without importing from a -conftest. Fixtures live in concern-focused modules — ``fixtures.clients`` (config -+ REST/WS clients), ``fixtures.rooms`` (room allocation + agent identity), -``fixtures.memory`` (memory toolkit) — and are imported into this conftest's -namespace below so they stay scoped to ``tests/e2e/`` (``pytest_plugins`` is only -honored in the top-level conftest). +Configuration is loaded from .env.test with E2E-specific overrides from env vars. """ from __future__ import annotations +import logging +import os +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator from pathlib import Path +from typing import TYPE_CHECKING import pytest - -# Registering fixtures: pytest discovers fixtures imported into a conftest's -# namespace. The fixture modules import only from ``tests.e2e.settings`` (never -# this conftest), so these imports are free of circular dependencies. -from tests.e2e.fixtures.clients import ( # noqa: F401 - api_client, - e2e_config, - e2e_created_room_ids, - e2e_room_summary, - e2e_session_client, - e2e_session_client_2, - e2e_user_client, - ws_client, -) -from tests.e2e.fixtures.memory import memory # noqa: F401 -from tests.e2e.fixtures.rooms import ( # noqa: F401 - adapter_entry, - e2e_adapter_room, - e2e_agent_id, - e2e_agent_info, - e2e_agent_info_2, - e2e_fresh_room_allocator, - e2e_isolation_room_b, - e2e_parlant_room, - e2e_room_allocator, +from dotenv import load_dotenv +from pydantic import ValidationError +from band_rest import AsyncRestClient, ChatRoomRequest +from band_rest.types import ( + ParticipantRequest, ) +from thenvoi_testing.settings import BaseTestSettings + +from band.client.streaming import WebSocketClient + +from tests.conftest_integration import is_room_alive +from tests.e2e.helpers import TrackingWebSocketClient + +# Load .env.test into os.environ so LLM libraries (langchain, anthropic, etc.) +# can pick up OPENAI_API_KEY, ANTHROPIC_API_KEY, and other keys. +_ENV_TEST_PATH = Path(__file__).parent.parent.parent / ".env.test" +load_dotenv(_ENV_TEST_PATH, override=False) + +if TYPE_CHECKING: + from tests.e2e.adapters.conftest import AdapterFactory + +# NOTE: pytestmark in conftest.py is NOT applied to collected tests. +# The 120s timeout is applied via pytest_collection_modifyitems below. + +logger = logging.getLogger(__name__) def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @@ -70,3 +66,379 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: if Path(item.path).is_relative_to(e2e_dir): item.add_marker(session_marker) item.add_marker(timeout_marker) + + +# Platform limits agents to 10 active chat rooms; cap room searches accordingly. +_MAX_ROOMS_TO_SEARCH = 10 + + +# ============================================================================= +# E2E Settings +# ============================================================================= + + +class E2ESettings(BaseTestSettings): + """Settings for E2E tests, loaded from .env.test. + + Loads from .env.test and allows E2E-specific overrides via env vars. + Pydantic BaseSettings automatically maps environment variables to fields + (e.g. E2E_LLM_MODEL -> e2e_llm_model) with case-insensitive matching. + """ + + _env_file_path = Path(__file__).parent.parent.parent / ".env.test" + + band_api_key: str = "" + band_api_key_2: str = "" + band_api_key_user: str = "" + band_base_url: str = "http://localhost:4000" + band_ws_url: str = "ws://localhost:4000/api/v1/socket/websocket" + test_agent_id: str = "" + test_agent_id_2: str = "" + + # E2E-specific settings (override via environment variables) + e2e_llm_model: str = "gpt-5.4-mini" + e2e_anthropic_model: str = "claude-3-haiku-20240307" + e2e_timeout: int = 30 + e2e_tests_enabled: bool = False + + +# ============================================================================= +# Skip Markers +# ============================================================================= + + +def _check_e2e_status() -> tuple[bool, str]: + """Check if E2E tests should be skipped. + + Evaluated once at module import time (when the ``requires_e2e`` marker + is created). Returns ``(is_disabled, reason)`` so the skip message is + actionable. + """ + try: + settings = E2ESettings() + if not settings.e2e_tests_enabled: + return True, "E2E_TESTS_ENABLED is not set to true" + if not settings.band_api_key: + return True, "BAND_API_KEY is not set" + return False, "E2E tests enabled" + except (ValidationError, ValueError, OSError) as exc: + logger.warning( + "E2E settings could not be loaded (missing .env.test?), skipping E2E tests", + exc_info=True, + ) + return True, f"E2E settings could not be loaded: {exc}" + + +_e2e_is_disabled, _e2e_skip_reason = _check_e2e_status() + +requires_e2e = pytest.mark.skipif( + _e2e_is_disabled, + reason=_e2e_skip_reason or "E2E tests disabled", +) + +requires_openai = pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="OPENAI_API_KEY not set", +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture(scope="session") +def e2e_config() -> E2ESettings: + """Provide E2E settings to tests (session-scoped singleton).""" + return E2ESettings() + + +@pytest.fixture(scope="session") +def e2e_created_room_ids() -> list[str]: + """Session-scoped mutable list tracking room IDs created during the E2E run. + + A mutable container is needed because session-scoped fixtures (like the + room allocator) append to this list during the run, and the room summary + fixture reads it at teardown. Using a list (not a set) preserves + creation order for the summary log. + """ + return [] + + +@pytest.fixture(scope="session", autouse=True) +def e2e_room_summary(e2e_created_room_ids: list[str]) -> Generator[None, None, None]: + """Log a summary of rooms created during the E2E test session. + + Rooms persist on the platform (no delete API for agents), so this + summary helps operators track accumulation across runs. + """ + yield + if e2e_created_room_ids: + logger.info( + "E2E session created %d room(s) that will persist: %s", + len(e2e_created_room_ids), + ", ".join(e2e_created_room_ids), + ) + + +@pytest.fixture(scope="session") +def e2e_session_client( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client shared across all E2E fixtures. + + Avoids creating multiple short-lived AsyncRestClient instances in each + session-scoped fixture. AsyncRestClient has no close() method — the + underlying httpx client is managed internally. + """ + if not e2e_config.band_api_key: + pytest.skip("BAND_API_KEY not set") + + return AsyncRestClient( + api_key=e2e_config.band_api_key, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture(scope="session") +def e2e_user_client( + e2e_config: E2ESettings, +) -> AsyncRestClient: + """Session-scoped REST client authenticated as the User. + + Used by ``send_trigger_message`` so the trigger comes from the User + (not the agent). The agent runtime skips self-authored messages, so + using the agent client would silently fail to trigger processing. + """ + if not e2e_config.band_api_key_user: + pytest.skip("BAND_API_KEY_USER not set (needed for user REST client)") + + return AsyncRestClient( + api_key=e2e_config.band_api_key_user, + base_url=e2e_config.band_base_url, + ) + + +@pytest.fixture +def api_client( + e2e_user_client: AsyncRestClient, +) -> AsyncRestClient: + """Function-scoped alias for the user REST client. + + Tests inject ``api_client`` to send trigger messages. This now + resolves to the **user**-scoped client so the agent runtime correctly + processes the incoming message. + """ + return e2e_user_client + + +# ============================================================================= +# Per-Adapter Room Allocation +# ============================================================================= + + +# Async callable: adapter_name -> (room_id, user_id, user_name) +RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] + + +@pytest.fixture(scope="session") +async def e2e_room_allocator( + e2e_session_client: AsyncRestClient, + e2e_created_room_ids: list[str], +) -> RoomAllocator: + """Lazy per-adapter room allocator (session-scoped). + + Returns an async function ``allocate(name) -> (room_id, user_id, user_name)`` + that assigns a dedicated room to each adapter. Reuses existing rooms from + prior runs where possible; creates new rooms only when needed. + + The platform limits agents to 10 active rooms, and rooms persist (no delete + API). Each adapter gets its own room to avoid cross-adapter contamination + in room history. Expected allocation: 5 standard adapters + 1 Parlant + + 1 isolation Room B = 7 rooms max (well within the 10-room limit). + """ + client = e2e_session_client + cache: dict[str, tuple[str, str, str]] = {} + + # Find User peer once + peers_response = await client.agent_api_peers.list_agent_peers() + user_peer = next((p for p in peers_response.data if p.type == "User"), None) + if user_peer is None: + pytest.skip("No User peer available for E2E tests") + + # Collect existing rooms that are alive and already have this User peer. + # Rooms can be auto-deleted by the platform's 10-room limit, so we + # validate each room before considering it reusable. + chats_response = await client.agent_api_chats.list_agent_chats() + available_rooms: list[str] = [] + for room in (chats_response.data or [])[:_MAX_ROOMS_TO_SEARCH]: + if not await is_room_alive(client, room.id): + logger.warning("E2E: Room %s is deleted, skipping", room.id) + continue + participants_response = ( + await client.agent_api_participants.list_agent_chat_participants(room.id) + ) + participant_ids = [p.id for p in (participants_response.data or [])] + if user_peer.id in participant_ids: + available_rooms.append(room.id) + + logger.info( + "E2E: Found %d existing room(s) with User peer %s", + len(available_rooms), + user_peer.name, + ) + + used_room_ids: set[str] = set() + + async def allocate(name: str) -> tuple[str, str, str]: + if name in cache: + return cache[name] + + # Try to reuse an unassigned existing room + for room_id in available_rooms: + if room_id not in used_room_ids: + used_room_ids.add(room_id) + result = (room_id, user_peer.id, user_peer.name) + cache[name] = result + logger.info("E2E: Reusing room %s for '%s'", room_id, name) + return result + + # No existing room available — create one + response = await client.agent_api_chats.create_agent_chat( + chat=ChatRoomRequest() + ) + if response.data is None: + pytest.fail("create_agent_chat returned no data") + room_id = response.data.id + await client.agent_api_participants.add_agent_chat_participant( + room_id, + participant=ParticipantRequest(participant_id=user_peer.id, role="member"), + ) + used_room_ids.add(room_id) + e2e_created_room_ids.append(room_id) + result = (room_id, user_peer.id, user_peer.name) + cache[name] = result + logger.info( + "E2E: Created room %s for '%s' (will persist, no delete API)", + room_id, + name, + ) + return result + + return allocate + + +@pytest.fixture +async def e2e_adapter_room( + adapter_entry: tuple[str, AdapterFactory], + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for the current parametrized adapter. + + Returns (room_id, user_id, user_name). Each adapter gets its own room + to avoid cross-adapter contamination in room history. + """ + name, _ = adapter_entry + return await e2e_room_allocator(name) + + +@pytest.fixture +async def e2e_parlant_room( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Dedicated room for Parlant adapter tests.""" + return await e2e_room_allocator("parlant") + + +@pytest.fixture +async def e2e_isolation_room_b( + e2e_room_allocator: RoomAllocator, +) -> tuple[str, str, str]: + """Shared Room B for room isolation tests. + + All adapters' isolation tests share this as their second room. + Room A is the adapter's own room (``e2e_adapter_room``). + """ + return await e2e_room_allocator("_isolation_b") + + +@pytest.fixture(scope="session") +async def e2e_agent_id(e2e_session_client: AsyncRestClient) -> str: + """Get the agent ID for the test agent (cached for the entire session). + + Note: Session-scoped because the agent ID is stable for a given API key + and never changes mid-run. If the underlying agent is recreated between + tests, this cached value would be stale — but that scenario doesn't + apply to E2E runs against a persistent platform. + """ + agent_me = await e2e_session_client.agent_api_identity.get_agent_me() + return agent_me.data.id + + +@pytest.fixture(scope="session") +async def e2e_agent_info(e2e_session_client: AsyncRestClient) -> tuple[str, str]: + """Get (agent_id, agent_name) for the test agent. + + Used by tests that need to @mention the agent in trigger messages. + """ + agent_me = await e2e_session_client.agent_api_identity.get_agent_me() + return agent_me.data.id, agent_me.data.name + + +@pytest.fixture(scope="session") +async def ws_client( + e2e_config: E2ESettings, +) -> AsyncGenerator[TrackingWebSocketClient, None]: + """Session-scoped WebSocket client for observing agent responses. + + Connects as the **User** (via ``band_api_key_user``) rather than + the agent. The platform enforces one WS connection per agent, so a + second agent connection would kill the Agent's own connection. The + User is a room participant and receives the same ``message_created`` + events, making it a safe observer that coexists with the Agent. + + Session-scoped to avoid creating/tearing down a WS connection per test, + which adds latency and can cause flakiness. + + Wraps the raw WebSocketClient in a TrackingWebSocketClient that tracks + joined channels and explicitly leaves them on teardown. + """ + if not e2e_config.band_api_key_user: + pytest.skip("BAND_API_KEY_USER not set (needed for WS observer)") + + ws = WebSocketClient( + ws_url=e2e_config.band_ws_url, + api_key=e2e_config.band_api_key_user, + agent_id=None, # User connection, not agent + ) + + async with ws: + tracking_ws = TrackingWebSocketClient(ws) + yield tracking_ws + await tracking_ws.cleanup_channels() + + +@pytest.fixture( + params=[ + "langgraph", + "anthropic", + "pydantic_ai", + "claude_sdk", + "crewai", + ] +) +def adapter_entry( + request: pytest.FixtureRequest, +) -> tuple[str, AdapterFactory]: + """Parametrized fixture yielding (name, factory) for each adapter. + + Defined here (e2e/conftest.py) so both adapters/ and scenarios/ tests + share a single definition. The ADAPTER_FACTORIES import is deferred to + avoid a circular dependency (adapters/conftest.py imports E2ESettings + from this module). The ``AdapterFactory`` type is imported under + ``TYPE_CHECKING`` for the same reason. + """ + from tests.e2e.adapters.conftest import ADAPTER_FACTORIES + + name: str = request.param + return name, ADAPTER_FACTORIES[name] diff --git a/tests/e2e/fixtures/__init__.py b/tests/e2e/fixtures/__init__.py deleted file mode 100644 index f079e2f84..000000000 --- a/tests/e2e/fixtures/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""E2E fixture modules, imported into ``tests/e2e/conftest.py``'s namespace. - -Split by concern: ``clients`` (config + REST/WS clients), ``rooms`` (room -allocation + agent identity), ``memory`` (memory-test toolkit). -""" - -from __future__ import annotations diff --git a/tests/e2e/fixtures/clients.py b/tests/e2e/fixtures/clients.py deleted file mode 100644 index 434bdbb05..000000000 --- a/tests/e2e/fixtures/clients.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Config + REST/WS client fixtures for E2E tests. - -Session-scoped singletons: the E2E settings, the agent/user REST clients, and -the User WebSocket observer. Also tracks rooms created during the run for the -end-of-session summary. -""" - -from __future__ import annotations - -import logging -from collections.abc import AsyncGenerator, Generator - -import pytest -from band_rest import AsyncRestClient - -from band.client.streaming import WebSocketClient - -from tests.e2e.settings import E2ESettings -from tests.e2e.helpers import TrackingWebSocketClient - -logger = logging.getLogger(__name__) - - -@pytest.fixture(scope="session") -def e2e_config() -> E2ESettings: - """Provide E2E settings to tests (session-scoped singleton).""" - return E2ESettings() - - -@pytest.fixture(scope="session") -def e2e_created_room_ids() -> list[str]: - """Session-scoped mutable list tracking room IDs created during the E2E run. - - A mutable container is needed because session-scoped fixtures (like the - room allocator) append to this list during the run, and the room summary - fixture reads it at teardown. Using a list (not a set) preserves - creation order for the summary log. - """ - return [] - - -@pytest.fixture(scope="session", autouse=True) -def e2e_room_summary(e2e_created_room_ids: list[str]) -> Generator[None, None, None]: - """Log a summary of rooms created during the E2E test session. - - Rooms persist on the platform (no delete API for agents), so this - summary helps operators track accumulation across runs. - """ - yield - if e2e_created_room_ids: - logger.info( - "E2E session created %d room(s) that will persist: %s", - len(e2e_created_room_ids), - ", ".join(e2e_created_room_ids), - ) - - -@pytest.fixture(scope="session") -def e2e_session_client( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client shared across all E2E fixtures. - - Avoids creating multiple short-lived AsyncRestClient instances in each - session-scoped fixture. AsyncRestClient has no close() method — the - underlying httpx client is managed internally. - """ - if not e2e_config.band_api_key: - pytest.skip("BAND_API_KEY not set") - - return AsyncRestClient( - api_key=e2e_config.band_api_key, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture(scope="session") -def e2e_user_client( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client authenticated as the User. - - Used by ``send_trigger_message`` so the trigger comes from the User - (not the agent). The agent runtime skips self-authored messages, so - using the agent client would silently fail to trigger processing. - """ - if not e2e_config.band_api_key_user: - pytest.skip("BAND_API_KEY_USER not set (needed for user REST client)") - - return AsyncRestClient( - api_key=e2e_config.band_api_key_user, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture -def api_client( - e2e_user_client: AsyncRestClient, -) -> AsyncRestClient: - """Function-scoped alias for the user REST client. - - Tests inject ``api_client`` to send trigger messages. This now - resolves to the **user**-scoped client so the agent runtime correctly - processes the incoming message. - """ - return e2e_user_client - - -@pytest.fixture(scope="session") -def e2e_session_client_2( - e2e_config: E2ESettings, -) -> AsyncRestClient: - """Session-scoped REST client for the *second* test agent. - - Multi-agent E2E tests need a distinct agent identity (different API key) - so two agents can coexist in the same room. Skips cleanly when the second - agent is not provisioned in .env.test. - """ - if not e2e_config.band_api_key_2: - pytest.skip("BAND_API_KEY_2 not set (needed for multi-agent E2E tests)") - - return AsyncRestClient( - api_key=e2e_config.band_api_key_2, - base_url=e2e_config.band_base_url, - ) - - -@pytest.fixture(scope="session") -async def ws_client( - e2e_config: E2ESettings, -) -> AsyncGenerator[TrackingWebSocketClient, None]: - """Session-scoped WebSocket client for observing agent responses. - - Connects as the **User** (via ``band_api_key_user``) rather than - the agent. The platform enforces one WS connection per agent, so a - second agent connection would kill the Agent's own connection. The - User is a room participant and receives the same ``message_created`` - events, making it a safe observer that coexists with the Agent. - - Session-scoped to avoid creating/tearing down a WS connection per test, - which adds latency and can cause flakiness. - - Wraps the raw WebSocketClient in a TrackingWebSocketClient that tracks - joined channels and explicitly leaves them on teardown. - """ - if not e2e_config.band_api_key_user: - pytest.skip("BAND_API_KEY_USER not set (needed for WS observer)") - - ws = WebSocketClient( - ws_url=e2e_config.band_ws_url, - api_key=e2e_config.band_api_key_user, - agent_id=None, # User connection, not agent - ) - - async with ws: - tracking_ws = TrackingWebSocketClient(ws) - yield tracking_ws - await tracking_ws.cleanup_channels() diff --git a/tests/e2e/fixtures/memory.py b/tests/e2e/fixtures/memory.py deleted file mode 100644 index 5c2848f26..000000000 --- a/tests/e2e/fixtures/memory.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Memory-test fixture: a per-test ``MemoryProbe`` that cleans up on teardown.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator - -import pytest -from band_rest import AsyncRestClient - -from tests.conftest_integration import is_no_clean_mode -from tests.e2e.settings import E2ESettings -from tests.e2e.helpers import MemoryProbe - - -@pytest.fixture -async def memory( - e2e_session_client: AsyncRestClient, - e2e_config: E2ESettings, - request: pytest.FixtureRequest, -) -> AsyncGenerator[MemoryProbe, None]: - """Memory-test toolkit: ``memory.marker(...)`` + ``await memory.wait(...)``. - - Archives whatever it matched on teardown (skipped under ``--no-clean`` / - ``BAND_TEST_NO_CLEAN``). Any memory-capable adapter test can depend on this. - """ - probe = MemoryProbe(e2e_session_client, default_timeout=e2e_config.e2e_timeout) - yield probe - if not is_no_clean_mode(request): - await probe.archive_all() diff --git a/tests/e2e/fixtures/rooms.py b/tests/e2e/fixtures/rooms.py deleted file mode 100644 index 485314c30..000000000 --- a/tests/e2e/fixtures/rooms.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Room allocation + agent identity fixtures for E2E tests. - -Two allocators: ``e2e_room_allocator`` reuses rooms across runs (to respect the -platform's 10-room cap) and ``e2e_fresh_room_allocator`` makes a clean room per -test and leaves it on teardown. Plus per-adapter room fixtures and the agent -identity lookups used to @mention agents in trigger messages. -""" - -from __future__ import annotations - -import contextlib -import logging -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING - -import pytest -from band_rest import AsyncRestClient, ChatRoomRequest -from band_rest.types import ParticipantRequest - -from tests.conftest_integration import is_no_clean_mode, is_room_alive -from tests.e2e.settings import RoomAllocator - -if TYPE_CHECKING: - from tests.e2e.adapters.conftest import AdapterFactory - -logger = logging.getLogger(__name__) - -# Platform limits agents to 10 active chat rooms; cap room searches accordingly. -_MAX_ROOMS_TO_SEARCH = 10 - - -@pytest.fixture(scope="session") -async def e2e_room_allocator( - e2e_session_client: AsyncRestClient, - e2e_created_room_ids: list[str], -) -> RoomAllocator: - """Lazy per-adapter room allocator (session-scoped). - - Returns an async function ``allocate(name) -> (room_id, user_id, user_name)`` - that assigns a dedicated room to each adapter. Reuses existing rooms from - prior runs where possible; creates new rooms only when needed. - - The platform limits agents to 10 active rooms, and rooms persist (no delete - API). Each adapter gets its own room to avoid cross-adapter contamination - in room history. Expected allocation: 5 standard adapters + 1 Parlant + - 1 isolation Room B = 7 rooms max (well within the 10-room limit). - """ - client = e2e_session_client - cache: dict[str, tuple[str, str, str]] = {} - - # Find User peer once - peers_response = await client.agent_api_peers.list_agent_peers() - user_peer = next((p for p in peers_response.data if p.type == "User"), None) - if user_peer is None: - pytest.skip("No User peer available for E2E tests") - - # Collect existing rooms that are alive and already have this User peer. - # Rooms can be auto-deleted by the platform's 10-room limit, so we - # validate each room before considering it reusable. - chats_response = await client.agent_api_chats.list_agent_chats() - available_rooms: list[str] = [] - for room in (chats_response.data or [])[:_MAX_ROOMS_TO_SEARCH]: - if not await is_room_alive(client, room.id): - logger.warning("E2E: Room %s is deleted, skipping", room.id) - continue - participants_response = ( - await client.agent_api_participants.list_agent_chat_participants(room.id) - ) - participant_ids = [p.id for p in (participants_response.data or [])] - if user_peer.id in participant_ids: - available_rooms.append(room.id) - - logger.info( - "E2E: Found %d existing room(s) with User peer %s", - len(available_rooms), - user_peer.name, - ) - - used_room_ids: set[str] = set() - - async def allocate(name: str) -> tuple[str, str, str]: - if name in cache: - return cache[name] - - # Try to reuse an unassigned existing room - for room_id in available_rooms: - if room_id not in used_room_ids: - used_room_ids.add(room_id) - result = (room_id, user_peer.id, user_peer.name) - cache[name] = result - logger.info("E2E: Reusing room %s for '%s'", room_id, name) - return result - - # No existing room available — create one - response = await client.agent_api_chats.create_agent_chat( - chat=ChatRoomRequest() - ) - if response.data is None: - pytest.fail("create_agent_chat returned no data") - room_id = response.data.id - await client.agent_api_participants.add_agent_chat_participant( - room_id, - participant=ParticipantRequest(participant_id=user_peer.id, role="member"), - ) - used_room_ids.add(room_id) - e2e_created_room_ids.append(room_id) - result = (room_id, user_peer.id, user_peer.name) - cache[name] = result - logger.info( - "E2E: Created room %s for '%s' (will persist, no delete API)", - room_id, - name, - ) - return result - - return allocate - - -@pytest.fixture -async def e2e_fresh_room_allocator( - e2e_session_client: AsyncRestClient, - e2e_created_room_ids: list[str], - request: pytest.FixtureRequest, -) -> AsyncGenerator[RoomAllocator, None]: - """Allocate a brand-new room on every call, then leave it on teardown. - - Unlike ``e2e_room_allocator`` (which reuses rooms), this always creates a - fresh room so the agent starts with a clean, uncontaminated history — use it - for tests sensitive to prior room content (e.g. memory tests). - - On teardown the agent is removed from each created room so they don't count - against its 10-room cap (there's no chat-delete API; removing the agent - participant frees the slot). Opt out with ``--no-clean`` / - ``BAND_TEST_NO_CLEAN`` to leave the rooms intact for debugging. - """ - client = e2e_session_client - - peers_response = await client.agent_api_peers.list_agent_peers() - user_peer = next((p for p in peers_response.data if p.type == "User"), None) - if user_peer is None: - pytest.skip("No User peer available for E2E tests") - agent_me = await client.agent_api_identity.get_agent_me() - agent_id = agent_me.data.id - - created: list[str] = [] - - async def allocate(name: str) -> tuple[str, str, str]: - response = await client.agent_api_chats.create_agent_chat( - chat=ChatRoomRequest(title=f"e2e-{name}") - ) - if response.data is None: - pytest.fail("create_agent_chat returned no data") - room_id = response.data.id - # Record for teardown immediately: the room (with the agent in it) now - # exists on the platform, so it must be cleaned up even if adding the - # user participant below fails — otherwise it leaks against the cap. - e2e_created_room_ids.append(room_id) - created.append(room_id) - await client.agent_api_participants.add_agent_chat_participant( - room_id, - participant=ParticipantRequest(participant_id=user_peer.id, role="member"), - ) - logger.info("E2E: Created fresh room %s for '%s'", room_id, name) - return room_id, user_peer.id, user_peer.name - - yield allocate - - if is_no_clean_mode(request): - return - for room_id in created: - with contextlib.suppress(Exception): - await client.agent_api_participants.remove_agent_chat_participant( - room_id, agent_id - ) - - -@pytest.fixture -async def e2e_adapter_room( - adapter_entry: tuple[str, AdapterFactory], - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for the current parametrized adapter. - - Returns (room_id, user_id, user_name). Each adapter gets its own room - to avoid cross-adapter contamination in room history. - """ - name, _ = adapter_entry - return await e2e_room_allocator(name) - - -@pytest.fixture -async def e2e_parlant_room( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Dedicated room for Parlant adapter tests.""" - return await e2e_room_allocator("parlant") - - -@pytest.fixture -async def e2e_isolation_room_b( - e2e_room_allocator: RoomAllocator, -) -> tuple[str, str, str]: - """Shared Room B for room isolation tests. - - All adapters' isolation tests share this as their second room. - Room A is the adapter's own room (``e2e_adapter_room``). - """ - return await e2e_room_allocator("_isolation_b") - - -@pytest.fixture(scope="session") -async def e2e_agent_id(e2e_session_client: AsyncRestClient) -> str: - """Get the agent ID for the test agent (cached for the entire session). - - Note: Session-scoped because the agent ID is stable for a given API key - and never changes mid-run. If the underlying agent is recreated between - tests, this cached value would be stale — but that scenario doesn't - apply to E2E runs against a persistent platform. - """ - agent_me = await e2e_session_client.agent_api_identity.get_agent_me() - return agent_me.data.id - - -@pytest.fixture(scope="session") -async def e2e_agent_info(e2e_session_client: AsyncRestClient) -> tuple[str, str]: - """Get (agent_id, agent_name) for the test agent. - - Used by tests that need to @mention the agent in trigger messages. - """ - agent_me = await e2e_session_client.agent_api_identity.get_agent_me() - return agent_me.data.id, agent_me.data.name - - -@pytest.fixture(scope="session") -async def e2e_agent_info_2( - e2e_session_client_2: AsyncRestClient, -) -> tuple[str, str]: - """Get (agent_id, agent_name) for the second test agent. - - Used by multi-agent tests to @mention the second agent and to verify it - was added to / removed from a room. - """ - agent_me = await e2e_session_client_2.agent_api_identity.get_agent_me() - return agent_me.data.id, agent_me.data.name - - -@pytest.fixture( - params=[ - "langgraph", - "anthropic", - "pydantic_ai", - "claude_sdk", - "crewai", - "agno", - ] -) -def adapter_entry( - request: pytest.FixtureRequest, -) -> tuple[str, AdapterFactory]: - """Parametrized fixture yielding (name, factory) for each adapter. - - The ADAPTER_FACTORIES import is deferred to avoid a circular dependency - (adapters/conftest.py imports E2ESettings from the e2e conftest). The - ``AdapterFactory`` type is imported under ``TYPE_CHECKING`` for the same - reason. - """ - from tests.e2e.adapters.conftest import ADAPTER_FACTORIES - - name: str = request.param - return name, ADAPTER_FACTORIES[name] diff --git a/tests/e2e/helpers/messaging.py b/tests/e2e/helpers.py similarity index 55% rename from tests/e2e/helpers/messaging.py rename to tests/e2e/helpers.py index 5b5e87319..825a6f83d 100644 --- a/tests/e2e/helpers/messaging.py +++ b/tests/e2e/helpers.py @@ -1,20 +1,15 @@ -"""Driving and observing chat rooms in E2E tests. +"""E2E test helper functions. -The core building blocks: ``TrackingWebSocketClient`` (a self-cleaning WS -wrapper), ``send_trigger_message`` / ``send_and_wait_for_reply`` to drive an -agent, the ``listening_for_*`` context managers to observe responses, and a few -assertion + smoke/tool workflow helpers. Prefer ``send_and_wait_for_reply`` over -hand-rolling the listen/send/wait dance. +Provides utilities for sending messages, waiting for agent responses, +and asserting on message content in E2E tests. """ from __future__ import annotations import asyncio -import json import logging from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager -from typing import Any from band_rest import AsyncRestClient, ChatMessageRequest from band_rest.types import ( @@ -106,48 +101,6 @@ async def send_trigger_message( return message_id -async def send_agent_message( - agent_client: AsyncRestClient, - room_id: str, - content: str, - mention_name: str, - mention_id: str, -) -> str: - """Send a message into a room **as an agent**, @mentioning a target. - - The agent-side mirror of :func:`send_trigger_message` (which sends as the - user). Used to produce multi-party "noise" — e.g. a second agent posting - chatter addressed to the user — without running that agent's own loop: we - only post via its REST client, so the message never cascades into an - inference on the sender. The @mention satisfies the platform's - "at least one mention" requirement and routes the message to *mention_id*, - not to the agent under test. - - Args: - agent_client: REST API client (the **sending agent's** credentials). - room_id: Chat room to send the message in. - content: Message content. - mention_name: Name of the participant to @mention. - mention_id: ID of the participant to @mention. - - Returns: - The message ID of the sent message. - """ - message_content = f"@{mention_name} {content}" - response = await agent_client.agent_api_messages.create_agent_chat_message( - room_id, - message=ChatMessageRequest( - content=message_content, - mentions=[Mention(id=mention_id, name=mention_name)], - ), - ) - message_id = response.data.id - logger.info( - "Agent sent message %s to room %s: %s", message_id, room_id, content[:80] - ) - return message_id - - @asynccontextmanager async def listening_for_agent_responses( ws_client: WebSocketClient | TrackingWebSocketClient, @@ -218,156 +171,6 @@ async def wait() -> list[MessageCreatedPayload]: await ws_client.leave_chat_room_channel(room_id) -@asynccontextmanager -async def listening_for_room_activity( - ws_client: WebSocketClient | TrackingWebSocketClient, - room_id: str, - *, - timeout: float = 30.0, - message_types: tuple[str, ...] = ("text",), - sender_id: str | None = None, - min_messages: int = 1, - stop_substring: str | None = None, - raise_on_timeout: bool = False, -) -> AsyncGenerator[Callable[[], Awaitable[list[MessageCreatedPayload]]], None]: - """Subscribe to a room and collect agent activity matching a filter. - - A generalized variant of :func:`listening_for_agent_responses` that can - capture non-text events (``thought``, ``tool_call``, ``tool_result``) and - optionally restrict to a single sender. Collects every ``message_created`` - payload from an Agent whose ``message_type`` is in *message_types* (and, - when *sender_id* is given, whose ``sender_id`` matches). - - Usage:: - - async with listening_for_room_activity( - ws, room_id, message_types=("thought",) - ) as wait: - await send_trigger_message(client, room_id, "Think it through", ...) - thoughts = await wait() - - Args: - ws_client: Connected WebSocket client (or TrackingWebSocketClient). - room_id: Chat room to listen on. - timeout: Maximum seconds ``wait()`` will block. - message_types: Message types to collect (default text only). - sender_id: If set, only collect activity from this sender. - min_messages: Minimum matching messages before ``wait()`` returns. - stop_substring: If set, ``wait()`` also completes as soon as a collected - payload's content contains this substring (case-insensitive), in - addition to the *min_messages* rule. Useful for a liveness-probe - sentinel: the returned list is then exactly the agent's replies from - subscription through the probe answer, so their *count* is - meaningful (e.g. exactly one ⇒ the agent answered only the probe). - raise_on_timeout: If True, ``wait()`` raises ``TimeoutError`` instead - of returning partial results. - - Yields: - An async callable that blocks until *min_messages* matching messages - arrive (or a *stop_substring* match, or *timeout* elapses) and returns - the collected payloads. - """ - received: list[MessageCreatedPayload] = [] - event = asyncio.Event() - stop_needle = stop_substring.lower() if stop_substring is not None else None - - async def handler(payload: MessageCreatedPayload) -> None: - if payload.sender_type != "Agent" or payload.message_type not in message_types: - return - if sender_id is not None and payload.sender_id != sender_id: - return - received.append(payload) - logger.info( - "Received %s from %s in room %s: %s", - payload.message_type, - payload.sender_name or payload.sender_id, - room_id, - payload.content[:80], - ) - matched_stop = ( - stop_needle is not None and stop_needle in payload.content.lower() - ) - if len(received) >= min_messages or matched_stop: - event.set() - - await ws_client.join_chat_room_channel(room_id, handler) - try: - - async def wait() -> list[MessageCreatedPayload]: - try: - await asyncio.wait_for(event.wait(), timeout=timeout) - except TimeoutError: - logger.warning( - "Timeout waiting for %s in room %s (received %d/%d after %.1fs)", - message_types, - room_id, - len(received), - min_messages, - timeout, - ) - if raise_on_timeout: - raise - return received - - yield wait - finally: - await ws_client.leave_chat_room_channel(room_id) - - -async def send_and_wait_for_reply( - ws_client: TrackingWebSocketClient, - user_client: AsyncRestClient, - chat_id: str, - prompt: str, - agent_name: str, - agent_id: str, - *, - timeout: float, -) -> None: - """Send a trigger message as the user and block until the agent replies (or timeout). - - The standard way to drive an agent in an E2E test — use instead of hand-rolling the - listen/send/wait dance. Raises on timeout. - """ - async with listening_for_agent_responses( - ws_client, chat_id, timeout=timeout, raise_on_timeout=True - ) as wait_for_reply: - await send_trigger_message(user_client, chat_id, prompt, agent_name, agent_id) - await wait_for_reply() - - -def find_tool_call_in_context(items: list[Any], tool_name: str) -> bool: - """Return True if any context item is a ``tool_call`` event for *tool_name*. - - The Agno adapter posts tool executions as ``tool_call`` events whose - ``content`` is a JSON object ``{"name": ..., "args": ..., ...}`` (see - ``AgnoAdapter._emit_execution``). This parses those payloads and matches - on the tool name, falling back to a substring check if the content is not - valid JSON. - - Args: - items: Context items from ``fetch_all_context`` (each has - ``message_type`` and ``content`` attributes). - tool_name: The tool name to look for (e.g. ``"add_numbers"``). - """ - matches = ( - _tool_call_name_matches(item, tool_name) - for item in items - if getattr(item, "message_type", None) == "tool_call" - ) - return any(matches) - - -def _tool_call_name_matches(item: Any, tool_name: str) -> bool: - """Check a single ``tool_call`` context item against *tool_name*.""" - content = getattr(item, "content", "") or "" - try: - parsed = json.loads(content) - except (json.JSONDecodeError, TypeError): - return tool_name in content - return isinstance(parsed, dict) and parsed.get("name") == tool_name - - def assert_content_contains( messages: list[MessageCreatedPayload], expected_substring: str, @@ -410,6 +213,11 @@ def assert_no_content_contains( ) +# ============================================================================= +# Shared Test Workflows +# ============================================================================= + + async def run_smoke_test( ws_client: TrackingWebSocketClient, api_client: AsyncRestClient, diff --git a/tests/e2e/helpers/__init__.py b/tests/e2e/helpers/__init__.py deleted file mode 100644 index 2ab36eeb7..000000000 --- a/tests/e2e/helpers/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""E2E test helpers. - -Split by concern: ``log`` (pretty transcript), ``messaging`` (drive/observe -chat rooms), ``agent`` (agent lifecycle), ``memory`` (memory-test toolkit). -Import the public helpers straight from ``tests.e2e.helpers``; the submodules -are an implementation detail. -""" - -from __future__ import annotations - -from tests.e2e.helpers.agent import running_agent -from tests.e2e.helpers.log import log_banner, log_step -from tests.e2e.helpers.memory import MemoryProbe -from tests.e2e.helpers.messaging import ( - TrackingWebSocketClient, - assert_content_contains, - assert_no_content_contains, - find_tool_call_in_context, - listening_for_agent_responses, - listening_for_room_activity, - run_smoke_test, - run_tool_execution_test, - send_agent_message, - send_and_wait_for_reply, - send_trigger_message, -) - -__all__ = [ - "MemoryProbe", - "TrackingWebSocketClient", - "assert_content_contains", - "assert_no_content_contains", - "find_tool_call_in_context", - "listening_for_agent_responses", - "listening_for_room_activity", - "log_banner", - "log_step", - "run_smoke_test", - "run_tool_execution_test", - "running_agent", - "send_agent_message", - "send_and_wait_for_reply", - "send_trigger_message", -] diff --git a/tests/e2e/helpers/agent.py b/tests/e2e/helpers/agent.py deleted file mode 100644 index 078dc75e5..000000000 --- a/tests/e2e/helpers/agent.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Agent lifecycle for E2E tests: start an agent with rate-limit-aware reconnect. - -Use the ``running_agent`` context manager to start/stop an agent around a test. -""" - -from __future__ import annotations - -import asyncio -import contextlib -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any - -from band.agent import Agent -from band.client.streaming.errors import WebSocketUpgradeError -from band.core.simple_adapter import SimpleAdapter - -from tests.e2e.helpers.log import log_step - -if TYPE_CHECKING: - from tests.e2e.settings import E2ESettings - -# The platform rate-limits how often one agent_id may reopen its WebSocket after -# a recent supersede (HTTP 429); a fresh agent is built per attempt so a partial -# start never leaves a half-connected agent behind. -_RETRYABLE_WS_STATUS = frozenset({429, 503}) -_MAX_CONNECT_ATTEMPTS = 6 - - -async def _connect_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> Agent: - """Create and start an agent, retrying rate-limited (HTTP 429/503) connects. - - Waits the server-supplied ``retry_after`` (else exponential backoff) for up - to ``_MAX_CONNECT_ATTEMPTS`` tries. - """ - for attempt in range(1, _MAX_CONNECT_ATTEMPTS + 1): - agent = Agent.create( - adapter=adapter, - agent_id=agent_id, - api_key=api_key, - ws_url=config.band_ws_url, - rest_url=config.band_base_url, - ) - try: - await agent.start() - return agent - except WebSocketUpgradeError as exc: - with contextlib.suppress(Exception): - await agent.stop() - last_attempt = attempt == _MAX_CONNECT_ATTEMPTS - if exc.status_code not in _RETRYABLE_WS_STATUS or last_attempt: - raise - cooldown = float(exc.retry_after or min(2**attempt, 30)) - log_step( - "retry", - f"WebSocket rate-limited (HTTP {exc.status_code}); cooling down " - f"{cooldown:.0f}s before attempt {attempt + 1}", - ) - await asyncio.sleep(cooldown) - raise AssertionError("unreachable: loop returns or raises") - - -@asynccontextmanager -async def running_agent( - adapter: SimpleAdapter[Any], - *, - agent_id: str, - api_key: str, - config: E2ESettings, -) -> AsyncGenerator[Agent, None]: - """Run a started agent for the duration of the ``async with`` block.""" - agent = await _connect_agent( - adapter, agent_id=agent_id, api_key=api_key, config=config - ) - try: - yield agent - finally: - await agent.stop() diff --git a/tests/e2e/helpers/log.py b/tests/e2e/helpers/log.py deleted file mode 100644 index 601a8e17a..000000000 --- a/tests/e2e/helpers/log.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Pretty logging for E2E tests — a followable transcript under ``pytest -s``. - -Use ``log_banner`` for section headers and ``log_step`` for in-scenario markers. -""" - -from __future__ import annotations - -from rich.console import Console -from rich.panel import Panel -from rich.text import Text - -# Rich renders a readable transcript under ``pytest -s`` and degrades to plain -# text when stdout is captured/non-tty. All dynamic text is passed through -# ``rich.text.Text`` (no markup parsing) so values like "[Milk $3.50]" can't -# be misread as style tags. -_console = Console() - -# Style + icon per step kind. Numeric/other steps fall back to the default. -_STEP_KINDS: dict[str, tuple[str, str]] = { - "assert": ("bold green", "✔"), - "restart": ("bold yellow", "⟳"), - "retry": ("bold dark_orange", "↻"), -} -_STEP_DEFAULT: tuple[str, str] = ("bold cyan", "▶") - - -def log_banner(title: str) -> None: - """Render a boxed section banner; green when it announces a pass.""" - passed = "PASS" in title.upper() - _console.print() - _console.print( - Panel( - Text(title, style="bold green" if passed else "bold bright_white"), - border_style="green" if passed else "bright_cyan", - padding=(0, 2), - expand=True, - ) - ) - - -def log_step(n: int | str | float, text: str) -> None: - """Render a color/icon-coded step marker within a scenario.""" - style, icon = _STEP_KINDS.get(str(n), _STEP_DEFAULT) - label = str(n) if str(n) in _STEP_KINDS else f"step {n}" - line = Text(" ") - line.append(f"{icon} {label}", style=style) - line.append(" ") - line.append(text, style="white") - _console.print(line) diff --git a/tests/e2e/helpers/memory.py b/tests/e2e/helpers/memory.py deleted file mode 100644 index 499acc300..000000000 --- a/tests/e2e/helpers/memory.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Memory-test toolkit: markers, polling, and teardown archival. - -Tests get ``MemoryProbe`` from the ``memory`` fixture (see conftest). New -memory tests should reuse it rather than hand-rolling memory REST calls. -""" - -from __future__ import annotations - -import asyncio -import contextlib -from datetime import datetime, timezone -from typing import Any -from uuid import uuid4 - -import pytest -from band_rest import AsyncRestClient - - -class MemoryProbe: - """Memory-test helper: make markers, poll for stored memories, auto-archive on teardown. - - Get it from the ``memory`` fixture. Typical use:: - - marker = memory.marker("BADGE") # always make markers this way - ...trigger the agent... - matches = await memory.wait(marker, scope="subject", subject_id=user_id) - - Subject scope REQUIRES ``subject_id`` (else the list API returns nothing); ``wait()`` - raises if it's missing so you can't silently time out on that mistake. - """ - - def __init__(self, client: AsyncRestClient, *, default_timeout: float) -> None: - self._client = client - self._default_timeout = default_timeout - self._ids: list[str] = [] - - def marker(self, prefix: str) -> str: - """Return a unique marker the LLM keeps verbatim (prefix + timestamp + random). - - Opaque tokens get dropped when the model rewrites a fact, so weave the result in as - the fact's substance (e.g. ``f"badge number is {marker}"``). - """ - ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - return f"{prefix}-{ts}-{uuid4().hex[:8]}" - - async def wait( - self, - marker: str, - *, - scope: str, - subject_id: str | None = None, - timeout: float | None = None, - ) -> list[Any]: - """Poll active ``scope`` memories until one's content contains ``marker``. - - Returns the matches and tracks their ids for teardown archival. Raises - ``ValueError`` for subject scope without ``subject_id`` (the list API returns - nothing without it); ``pytest.fail`` on timeout. ``timeout`` defaults to the - configured E2E timeout. - """ - if scope == "subject" and subject_id is None: - raise ValueError('scope="subject" requires subject_id') - - kwargs: dict[str, Any] = {"page_size": 50, "status": "active", "scope": scope} - if subject_id is not None: - kwargs["subject_id"] = subject_id - - deadline = asyncio.get_running_loop().time() + ( - timeout or self._default_timeout - ) - while asyncio.get_running_loop().time() < deadline: - response = await self._client.agent_api_memories.list_agent_memories( - **kwargs - ) - matches = [ - memory - for memory in response.data or [] - if marker in (getattr(memory, "content", None) or "") - ] - if matches: - self._ids.extend(m.id for m in matches) - return matches - await asyncio.sleep(1) - - pytest.fail(f"Expected {scope} memory containing {marker}") - - async def archive_all(self) -> None: - """Archive every memory matched via ``wait`` (best effort). Called on teardown.""" - for memory_id in self._ids: - with contextlib.suppress(Exception): - await self._client.agent_api_memories.archive_agent_memory(id=memory_id) diff --git a/tests/e2e/scenarios/agno/README.md b/tests/e2e/scenarios/agno/README.md deleted file mode 100644 index c131e2652..000000000 --- a/tests/e2e/scenarios/agno/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Agno adapter — E2E scenarios - -Live, multi-agent E2E tests for the Agno adapter. They run real Agno agents -against a real Band platform with a real LLM and assert on platform state via -**direct REST queries** (not just WebSocket observation). - -> The generic smoke / tool-execution coverage for Agno already runs via the -> parametrized suite in `tests/e2e/adapters/test_all_adapters.py`. This folder -> covers behavior that suite can't: multi-agent orchestration, history -> rehydration across restarts, and reasoning-as-thoughts emission. - -## What each test verifies - -| Test | Flow | Key assertion | -|------|------|---------------| -| `test_multi_agent.py::…invites_calculator_for_total` | Assistant (A) chats about a grocery list, invites a calculator agent (B), asks for the total, then removes B. | B's `add_numbers` tool actually ran (REST), total reported, B removed. | -| `test_multi_agent.py::…survives_restart[A/B/both]` | Same flow, but an agent is killed and restarted mid-conversation. | The restarted agent rehydrates history (`is_session_bootstrap`) and continues; tool runs again. | -| `test_thoughts.py::…emits_thought_events` | A single `reasoning=True` agent answers a step-by-step question. | A `thought` event is emitted (verified via REST). | - -## Cast - -- **Agent A — assistant** (`build_assistant_adapter`): no tools of its own, but - gets Band's chat/participant tools by default. Orchestrates B. -- **Agent B — calculator** (`create_calculator_agno_adapter`): owns a native - `add_numbers` tool; `Emit.EXECUTION` posts its `tool_call`/`tool_result` so - the run is observable. -- **User**: sends the trigger messages and observes via WebSocket. - -## Prerequisites - -Set in `.env.test` (tests `skip` cleanly if missing): - -- `BAND_API_KEY`, `TEST_AGENT_ID` — agent A -- `BAND_API_KEY_2`, `TEST_AGENT_ID_2` — agent B (must be discoverable by A) -- `BAND_API_KEY_USER` — the user/observer -- `ANTHROPIC_API_KEY` — the LLM -- `E2E_TESTS_ENABLED=true` - -## Run - -```bash -# Whole folder (use --log-cli-level=INFO to watch the transcript live) -E2E_TESTS_ENABLED=true uv run pytest tests/e2e/scenarios/agno/ -v -s --no-cov --log-cli-level=INFO - -# One restart variant -E2E_TESTS_ENABLED=true uv run pytest \ - "tests/e2e/scenarios/agno/test_multi_agent.py::TestAgnoMultiAgent::test_multi_agent_survives_restart[A]" \ - -v -s --no-cov --log-cli-level=INFO -``` - -`-s` is required to see the Rich step transcript; `--log-cli-level=INFO` -streams the per-message logs (only shown on failure otherwise). - -## Findings baked into these tests - -- **Events are observed via REST, not WebSocket.** Agent-emitted events - (`thought`, `tool_call`, `tool_result`) are returned by `agent_api_context` - but are **not** delivered over the user's WebSocket `message_created` stream - (that carries only `text`). So the tests **synchronize on the agent's `text` - reply over WS, then assert events via REST** (`fetch_all_context`). -- **WebSocket reconnect is rate-limited (HTTP 429).** Restart scenarios stop and - start the same agent rapidly, which the platform throttles "after a recent - supersede." `running_agent` retries the connect with tenacity, honoring the - server-supplied `retry_after`. Running the whole folder in one shot may pause - for these cooldowns. -- **`reasoning=True` is fragile with Band tools.** Anthropic's stricter - reasoning-mode tool validation can reject a Band tool schema (an `integer` - with `maximum`), emptying the reasoning step. The agent still answers; the - thought is asserted via REST. Tests carry `@flaky(reruns=2)` for LLM - nondeterminism. - -## Layout - -- `conftest.py` — adapter builders, grocery fixture data, REST assertion - helpers, the `running_agent` lifecycle (with tenacity reconnect retry), and - room fixtures. -- `test_multi_agent.py` — Scenarios 1 & 2. -- `test_thoughts.py` — Scenario 3. diff --git a/tests/e2e/scenarios/agno/__init__.py b/tests/e2e/scenarios/agno/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/e2e/scenarios/agno/conftest.py b/tests/e2e/scenarios/agno/conftest.py deleted file mode 100644 index 9866ea3b8..000000000 --- a/tests/e2e/scenarios/agno/conftest.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Shared fixtures and helpers for Agno E2E scenarios. - -Agno-specific building blocks live here so the scenario test modules stay -focused on the flow being verified: - -- adapter builders (``create_calculator_agno_adapter``, ``build_assistant_adapter``, - ``build_thinking_adapter``) -- the grocery-list fixture data used by the multi-agent scenarios -- direct-REST assertion helpers (tool execution, reported total, participant - presence) -- dedicated room fixtures - -Generic, framework-agnostic E2E utilities (WebSocket listeners, trigger -messages, pretty logging, the second-agent fixtures, and the ``running_agent`` -lifecycle context manager) remain in ``tests/e2e/helpers.py`` and -``tests/e2e/conftest.py``. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING, Any - -from band_rest import AsyncRestClient - -from band.core.simple_adapter import SimpleAdapter - -from tests.conftest_integration import fetch_all_context -from tests.e2e.adapters.conftest import _require_anthropic_key -from tests.e2e.settings import E2ESettings -from tests.e2e.helpers import find_tool_call_in_context, log_step - -if TYPE_CHECKING: - from band.adapters.agno import AgnoAdapter - -logger = logging.getLogger(__name__) - -CALCULATOR_TOOL = "add_numbers" - -# Grocery prices chosen to sum cleanly in float (no rounding surprises) to a -# distinctive total. Keep representations the LLM is likely to echo. -GROCERY_ITEMS: list[tuple[str, float]] = [ - ("Milk", 3.50), - ("Bread", 2.50), - ("Eggs", 5.00), - ("Coffee", 12.00), - ("Cheese", 7.50), -] -GROCERY_TOTAL = sum(price for _, price in GROCERY_ITEMS) # 30.50 -# Accept both "30.5" and "30.50" formatting from the model. -TOTAL_STRINGS = ("30.50", "30.5") - - -def grocery_list_text() -> str: - """Render the grocery list with prices as a single user-facing line.""" - return ", ".join(f"{name} ${price:.2f}" for name, price in GROCERY_ITEMS) - - -# ============================================================================= -# Adapter builders -# ============================================================================= - - -def add_numbers(numbers: list[float]) -> float: - """Add a list of numbers and return the total. - - Native Agno tool used by the calculator agent in the multi-agent scenarios. - """ - total = sum(numbers) - logger.info("Calculator tool add_numbers(%s) -> %s", numbers, total) - return total - - -def create_calculator_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Create an Agno "calculator" adapter that reports tool executions. - - The agent owns a native ``add_numbers`` tool; ``Emit.EXECUTION`` makes the - adapter post ``tool_call``/``tool_result`` events to the room so a test can - verify (via direct REST query) that the tool actually ran. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - from band.core.types import AdapterFeatures, Emit - - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - instructions=( - "You are a calculator agent. When asked to add up numbers, you MUST " - "use the add_numbers tool to compute the total -- never do the " - "arithmetic yourself. Reply with the total using the band_send_message " - "tool. Keep responses short." - ), - tools=[add_numbers], - ) - return AgnoAdapter( - agno_agent, - features=AdapterFeatures(emit={Emit.EXECUTION}), - ) - - -def build_assistant_adapter( - settings: E2ESettings, - *, - calculator_id: str, - calculator_name: str, -) -> SimpleAdapter[Any]: - """Build the "helpful assistant" Agno adapter (Agent A). - - The assistant has no tools of its own but receives Band's chat/participant - tools by default. Its instructions direct it to bring in the calculator - agent, ask it for the total, relay the answer, and remove it. - """ - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - - instructions = ( - "You are a helpful shopping assistant chatting with a user about their " - "grocery list. You are TERRIBLE at arithmetic and must NEVER add numbers " - "yourself. There is a calculator agent you can bring into the room:\n" - f" - name: {calculator_name}\n" - f" - id: {calculator_id}\n" - "When the user asks for the total cost, do ALL of the following, in order:\n" - f" 1. Call band_add_participant with identifier '{calculator_id}' to add " - "the calculator agent to this room.\n" - " 2. Call band_send_message with a message that @mentions the calculator " - f"(mention id {calculator_id}, name {calculator_name}), listing every item " - "and its price and asking it to add the prices up.\n" - " 3. When the calculator replies with the total, call band_send_message to " - "tell the user the total (mention the user).\n" - f" 4. Finally, call band_remove_participant with identifier " - f"'{calculator_id}' to remove the calculator agent from the room.\n" - "Keep every message short." - ) - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - instructions=instructions, - ) - return AgnoAdapter(agno_agent) - - -def build_db_backed_agno_adapter( - settings: E2ESettings, - *, - db: Any, - session_id: str, -) -> "AgnoAdapter": - """Build an Agno adapter whose agent owns its history via a database. - - ``add_history_to_context=True`` with a ``db`` makes Agno persist and replay - prior turns itself (keyed by ``session_id``). The AgnoAdapter detects this - and disables Band's own history rehydration, so prior context comes from - Agno alone and is not duplicated. Passing the *same* ``db`` object and - ``session_id`` to a second adapter instance models a restart against a - persistent backend. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - db=db, - session_id=session_id, - add_history_to_context=True, - instructions=( - "You are a helpful assistant with a long-term memory. Whenever you " - "acknowledge OR recall a value you were asked to remember, you MUST " - "include the exact value verbatim in your reply. Keep responses short." - ), - ) - return AgnoAdapter(agno_agent) - - -def build_thinking_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Build an Agno adapter with reasoning enabled and thought reporting on. - - The Claude model is created with native extended thinking enabled - (``thinking=...``). That makes Agno treat it as a native reasoning model and - populate ``reasoning_content`` from Claude's own thinking output; without it, - Agno falls back to a structured-output chain-of-thought agent that returns - empty content for Claude, leaving ``reasoning_content`` blank and no thought - to emit. ``reasoning=True`` selects the native-reasoning dispatch and - ``Emit.THOUGHTS`` makes the adapter post that reasoning as a ``thought`` - event to the room. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - from band.core.types import AdapterFeatures, Emit - - agno_agent = AgnoAgent( - model=Claude( - id=settings.e2e_anthropic_model, - thinking={"type": "enabled", "budget_tokens": 1024}, - ), - instructions=( - "You are a careful assistant. Think through problems step by step " - "before answering. Keep your final answer short." - ), - reasoning=True, - ) - return AgnoAdapter( - agno_agent, - features=AdapterFeatures(emit={Emit.THOUGHTS}), - ) - - -# ============================================================================= -# Lifecycle + assertion helpers -# ============================================================================= - - -async def wait_participant_absent( - client: AsyncRestClient, - room_id: str, - participant_id: str, - *, - timeout: float = 30.0, - poll_interval: float = 3.0, -) -> bool: - """Poll the participant list until *participant_id* is gone or timeout.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - resp = await client.agent_api_participants.list_agent_chat_participants(room_id) - ids = [p.id for p in (resp.data or [])] - if participant_id not in ids: - return True - await asyncio.sleep(poll_interval) - return False - - -async def participant_present( - client: AsyncRestClient, - room_id: str, - participant_id: str, -) -> bool: - """Return True if *participant_id* is currently a room participant.""" - resp = await client.agent_api_participants.list_agent_chat_participants(room_id) - return participant_id in [p.id for p in (resp.data or [])] - - -async def assert_calculator_ran( - calculator_client: AsyncRestClient, - room_id: str, -) -> None: - """Assert (via direct REST query) the calculator's tool actually executed. - - Queries with the calculator's own client so its emitted ``tool_call`` - events are visible, then checks for an ``add_numbers`` execution. - """ - items = await fetch_all_context(calculator_client, room_id) - used = find_tool_call_in_context(items, CALCULATOR_TOOL) - assert used, ( - f"Expected a '{CALCULATOR_TOOL}' tool_call event in room {room_id}, " - f"but found none in {len(items)} context item(s). The calculator agent " - "did not run its tool." - ) - log_step("assert", f"calculator tool '{CALCULATOR_TOOL}' executed") - - -async def assert_thought_emitted( - client: AsyncRestClient, - room_id: str, -) -> list[Any]: - """Assert (via direct REST query) at least one ``thought`` event exists. - - Agent-emitted events (``thought``, ``tool_call``, ``tool_result``) are - surfaced by the ``agent_api_context`` endpoint but are NOT delivered over - the user's WebSocket ``message_created`` stream (which carries only - ``text``). Always assert events via REST, not the socket. - """ - items = await fetch_all_context(client, room_id) - thoughts = [ - item for item in items if getattr(item, "message_type", None) == "thought" - ] - assert thoughts, ( - f"Expected a 'thought' event in room {room_id} context, but found none " - f"among {len(items)} item(s). The reasoning agent did not emit a thought." - ) - log_step("assert", f"{len(thoughts)} thought event(s) present via REST") - return thoughts - - -async def assert_total_reported( - user_client: AsyncRestClient, - room_id: str, -) -> None: - """Assert (via direct REST query) the total appears in a room message.""" - items = await fetch_all_context(user_client, room_id) - texts = [ - getattr(item, "content", "") or "" - for item in items - if getattr(item, "message_type", None) == "text" - ] - found = any(any(t in text for t in TOTAL_STRINGS) for text in texts) - assert found, ( - f"Expected the total ({GROCERY_TOTAL:.2f}) to appear in a room message, " - f"but it was not found among {len(texts)} text message(s)." - ) - log_step("assert", f"total {GROCERY_TOTAL:.2f} reported in room") diff --git a/tests/e2e/scenarios/agno/test_context_persistence.py b/tests/e2e/scenarios/agno/test_context_persistence.py deleted file mode 100644 index 4ab474699..000000000 --- a/tests/e2e/scenarios/agno/test_context_persistence.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Live: an Agno agent recalls prior conversation from Band history on rejoin. - -The shared cross-adapter recall test -(``tests/e2e/scenarios/test_context_persistence.py``) excludes Agno because two -of its behaviors break that test's assumptions without being SDK defects: - -1. On the shared, reused room a "secret code" prompt collides with standing - organization-scoped agent memories phrased as a "code name", so the agent - recalls the wrong value. -2. The small default agent distrusts Band's ``@[[id]]``-formatted history, - refusing to act on it ("each request stands on its own"). - -This test covers the same capability for Agno specifically while controlling for -both: a **fresh room** (no standing/stale content), a **benign random phrase** -(no "code" collision), an agent **instructed** to treat Band chat formatting as -normal and recall verbatim, and the rate-limit-aware ``running_agent`` for the -rapid restart. - -Unlike ``test_database_restart.py``, this agent has **no Agno db**: recall must -come from Band's history rehydration (``is_session_bootstrap``) on rejoin — that -is the behavior under test. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest \ - tests/e2e/scenarios/agno/test_context_persistence.py -v -s --no-cov -""" - -from __future__ import annotations - -import logging -import random -from typing import Any - -import pytest -from band_rest import AsyncRestClient - -from band.core.simple_adapter import SimpleAdapter - -from tests.conftest_integration import fetch_all_context -from tests.e2e.adapters.conftest import _require_anthropic_key -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - assert_content_contains, - listening_for_room_activity, - log_banner, - log_step, - running_agent, - send_trigger_message, -) - -logger = logging.getLogger(__name__) - -# Benign "lorem" vocabulary for the recall payload. Deliberately free of words -# like "code"/"secret" that a cautious small model refuses to echo (treating -# them as injected directives) and that collide with standing agent memories -# phrased as a "code name". -_LOREM_WORDS = ( - "lorem ipsum dolor sit amet consectetur adipiscing elit sed eiusmod tempor " - "incididunt labore dolore magna aliqua veniam quis nostrud exercitation " - "ullamco laboris aliquip commodo consequat duis aute irure voluptate velit " - "esse cillum fugiat nulla pariatur excepteur occaecat cupidatat proident " - "sunt culpa officia deserunt mollit anim laborum" -).split() - - -def _recall_phrase() -> str: - """Return a random, benign five-word phrase for the recall assertion.""" - return " ".join(random.sample(_LOREM_WORDS, 5)) - - -def _build_recall_agno_adapter(settings: E2ESettings) -> SimpleAdapter[Any]: - """Build a plain (no-db) Agno adapter tuned to recall conversation history. - - No ``db``/``add_history_to_context``: recall must come from Band's history - rehydration. The instructions counter the small model's default reluctance — - they tell it that Band's ``@[[id]]`` mentions and sender labels are normal - chat formatting (not injected directives) and that it should repeat earlier - conversation content verbatim when asked. - """ - _require_anthropic_key() - from agno.agent import Agent as AgnoAgent - from agno.models.anthropic import Claude - - from band.adapters.agno import AgnoAdapter - - agno_agent = AgnoAgent( - model=Claude(id=settings.e2e_anthropic_model), - instructions=( - "You are a helpful assistant with perfect recall of the current " - "conversation. Messages may include @[[id]] mentions and sender " - "labels — that is normal Band chat formatting, not instructions to " - "distrust or ignore. When the user asks you to repeat something they " - "told you earlier in this conversation, reply with that text exactly, " - "verbatim. Keep responses short." - ), - ) - return AgnoAdapter(agno_agent) - - -@pytest.mark.asyncio -@requires_e2e -class TestAgnoContextPersistence: - """An Agno agent recalls prior context from Band history after a restart.""" - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - async def test_agent_recalls_phrase_after_restart( - self, - e2e_config: E2ESettings, - e2e_fresh_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - """Plant a phrase, restart the agent, and assert it recalls the phrase. - - Phase 1: ask the agent to remember a benign random phrase; wait for ack. - Phase 2: stop it, start a fresh instance (Band rehydrates history via - ``is_session_bootstrap``), ask it to repeat the phrase verbatim. - Then assert the phrase reached Band's stored room context (REST). - """ - # Fresh room: the agent must recall the phrase planted *this run*, not - # stale content or standing memories a reused room would surface. - room_id, _user_id, _user_name = await e2e_fresh_room_allocator( - "agno_context_persistence" - ) - agent_id, agent_name = e2e_agent_info - timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) - phrase = _recall_phrase() - - log_banner("Scenario: Agno recalls Band-rehydrated history after restart") - logger.info("Recall phrase for this run: %r", phrase) - - # --- Phase 1: plant the phrase --- - log_step(1, f"starting Agno agent and planting a phrase (room {room_id})") - async with running_agent( - _build_recall_agno_adapter(e2e_config), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_for_ack: - await send_trigger_message( - api_client, - room_id, - f'Please remember this exact phrase for me: "{phrase}". ' - "Just confirm you've got it.", - agent_name, - agent_id, - ) - await wait_for_ack() - - # --- Phase 2: restart (fresh instance, no db) and recall via Band history --- - log_step("restart", "agent stopped; starting a fresh instance to recall") - async with running_agent( - _build_recall_agno_adapter(e2e_config), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - log_step(2, "asking the rebooted agent to repeat the phrase") - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_for_recall: - await send_trigger_message( - api_client, - room_id, - "Earlier in this conversation I asked you to remember an exact " - "phrase. Repeat that phrase back to me, word for word.", - agent_name, - agent_id, - ) - phase2_responses = await wait_for_recall() - - assert_content_contains(phase2_responses, phrase) - log_step("assert", "rebooted agent recalled the phrase from Band history") - - # The conversation persisted to Band infra and is retrievable via REST. - log_step(3, "verifying the phrase persisted to Band infra via REST") - items = await fetch_all_context(e2e_session_client, room_id) - texts = [ - getattr(item, "content", "") or "" - for item in items - if getattr(item, "message_type", None) == "text" - ] - assert any(phrase in text for text in texts), ( - f"Expected the phrase {phrase!r} in Band's stored room context, but " - f"it was absent from {len(texts)} text message(s)." - ) - log_step("assert", "phrase persisted to Band infra (REST context)") - - log_banner("Scenario PASSED") diff --git a/tests/e2e/scenarios/agno/test_database_restart.py b/tests/e2e/scenarios/agno/test_database_restart.py deleted file mode 100644 index eecd1e441..000000000 --- a/tests/e2e/scenarios/agno/test_database_restart.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Live smoke: a db-backed Agno agent survives a restart on the full Band stack. - -The live-platform counterpart to the in-process round-trip in -``tests/adapters/agno/test_history_persistence.py``. It exercises the full Band -stack with a real Agno agent whose history is owned by a database -(``add_history_to_context=True`` + ``db``): - -1. **Talk to it** — a user asks the agent to remember a secret code. -2. **Reboot it** — the agent is stopped and a fresh instance is started against - the *same* db object and ``session_id`` (a persistent backend outliving the - process). -3. **It remembers** — the rebooted agent reproduces the code after restart. -4. **History reached Band infra** — the conversation is retrievable from Band's - REST context. - -Scope (deliberately honest): this is a black-box integration test. It cannot -observe the model's assembled context, so it does NOT attempt to prove the -*source* of the recalled history (Agno's db vs. Band) or the absence of -duplication — in the live runtime, prior content can also surface via Band's -"answer the trailing unanswered message" bootstrap path, which the guard does -not govern. The rigorous proof that Band does not rehydrate and the context is -not duplicated lives in the unit test ``test_history_persistence`` (it controls -exactly what Band feeds and asserts it is dropped). Here we additionally assert -the guard is *engaged* in this configuration (``_agno_manages_history``) as a -cheap white-box check. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest \ - tests/e2e/scenarios/agno/test_database_restart.py -v -s --no-cov --log-cli-level=INFO -""" - -from __future__ import annotations - -import logging -import uuid - -import pytest -from agno.db.in_memory import InMemoryDb -from band_rest import AsyncRestClient - -from tests.conftest_integration import fetch_all_context -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - assert_content_contains, - listening_for_room_activity, - log_banner, - log_step, - running_agent, - send_trigger_message, -) -from tests.e2e.scenarios.agno.conftest import build_db_backed_agno_adapter - -logger = logging.getLogger(__name__) - - -@pytest.mark.asyncio -@requires_e2e -class TestAgnoDatabaseRestart: - """A db-backed Agno agent remembers across a restart on the live Band stack.""" - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - async def test_db_backed_agent_remembers_after_restart( - self, - e2e_config: E2ESettings, - e2e_fresh_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - # Fresh room (not the reusing allocator): this scenario relies on Agno's - # ephemeral db for memory, so a reused room's stale "remember X" messages - # would contaminate the recall assertion. - room_id, _user_id, _user_name = await e2e_fresh_room_allocator( - "agno_database_restart" - ) - agent_id, agent_name = e2e_agent_info - timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) - run_id = uuid.uuid4().hex[:6] - secret_code = f"SECRET-{run_id}" - - # One db object shared across the "reboot" models a persistent backend; - # the fixed session_id keys the agent's stored conversation. - db = InMemoryDb() - - log_banner(f"Scenario: Agno db-backed memory across restart (run {run_id})") - - # --- Phase 1: start the agent and have it store the secret --- - log_step(1, f"starting db-backed Agno agent (room {room_id})") - adapter = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) - - async with running_agent( - adapter, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - # Guard engaged after startup: detection runs in on_started, so by - # now the adapter has disabled Band's history rehydration. - assert adapter._agno_manages_history is True - log_step(2, f"user asks the agent to remember {secret_code}") - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_for_reply: - await send_trigger_message( - api_client, - room_id, - f"Please remember this secret code for later: {secret_code}. " - "Just confirm you will remember it.", - agent_name, - agent_id, - ) - await wait_for_reply() - - # --- Phase 2: reboot (fresh instance, same db + session) and recall --- - log_step("restart", "agent stopped; rebooting with the same db + session_id") - adapter2 = build_db_backed_agno_adapter(e2e_config, db=db, session_id=room_id) - - async with running_agent( - adapter2, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - assert adapter2._agno_manages_history is True - log_step(3, "user asks the rebooted agent to recall the code") - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_for_reply: - await send_trigger_message( - api_client, - room_id, - "What was the secret code I asked you to remember earlier? " - "Reply with just the code.", - agent_name, - agent_id, - ) - phase2_responses = await wait_for_reply() - - # The rebooted instance reproduces the code (db-backed memory survived the - # restart). Source attribution is unit-tested, not claimed here. - assert_content_contains(phase2_responses, secret_code) - log_step("assert", "rebooted agent reproduced the code after restart") - - # The conversation persisted to Band infra and is retrievable via REST. - log_step(4, "verifying the conversation persisted to Band infra via REST") - items = await fetch_all_context(e2e_session_client, room_id) - texts = [ - getattr(item, "content", "") or "" - for item in items - if getattr(item, "message_type", None) == "text" - ] - assert any(secret_code in text for text in texts), ( - f"Expected the secret code {secret_code} in Band's stored room " - f"context, but it was absent from {len(texts)} text message(s)." - ) - log_step("assert", "conversation persisted to Band infra (REST context)") - - log_banner(f"Scenario PASSED (run {run_id})") diff --git a/tests/e2e/scenarios/agno/test_multi_agent.py b/tests/e2e/scenarios/agno/test_multi_agent.py deleted file mode 100644 index f2af9e046..000000000 --- a/tests/e2e/scenarios/agno/test_multi_agent.py +++ /dev/null @@ -1,345 +0,0 @@ -"""E2E tests for multi-agent orchestration with the Agno adapter. - -Two real Agno agents and a user collaborate against the live Band platform: - -- **Agent A** (assistant): chats with the user about a grocery list. It cannot - do arithmetic, so it invites a calculator agent, asks it for the total, and - removes it when done. -- **Agent B** (calculator): owns a native ``add_numbers`` tool and reports its - executions via ``Emit.EXECUTION`` so the test can verify (by direct REST - query) that the tool actually ran. - -Scenario 1 (``test_assistant_invites_calculator_for_total``) runs the flow -straight through. Scenario 2 (``test_multi_agent_survives_restart``) kills and -restarts an agent mid-conversation to verify history rehydration, parametrized -over which agent restarts: A, B, or both. - -Requires a second provisioned agent (``BAND_API_KEY_2`` / ``TEST_AGENT_ID_2``) -that is discoverable by the first. Tests skip cleanly when it is absent. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest \ - tests/e2e/scenarios/agno/test_multi_agent.py -v -s --no-cov -""" - -from __future__ import annotations - -import asyncio -import logging -import uuid - -import pytest -from band_rest import AsyncRestClient - -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - listening_for_room_activity, - log_banner, - log_step, - running_agent, - send_trigger_message, -) -from tests.e2e.scenarios.agno.conftest import ( - GROCERY_TOTAL, - assert_calculator_ran, - assert_total_reported, - build_assistant_adapter, - create_calculator_agno_adapter, - grocery_list_text, - participant_present, - wait_participant_absent, -) - -logger = logging.getLogger(__name__) - -# A restarted agent reopens a WebSocket for an agent_id whose previous connection -# just closed. Reconnecting within the platform's supersede window returns HTTP -# 429, so pause briefly between a restart's kill and reconnect to let the old -# connection tear down and the rate-limit window clear. -_RESTART_RECONNECT_DELAY_S = 5.0 - - -@pytest.mark.asyncio -@requires_e2e -class TestAgnoMultiAgent: - """Multi-agent orchestration and rehydration tests for the Agno adapter.""" - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - async def test_assistant_invites_calculator_for_total( - self, - e2e_config: E2ESettings, - e2e_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_agent_info_2: tuple[str, str], - e2e_session_client: AsyncRestClient, - e2e_session_client_2: AsyncRestClient, - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - """Assistant brings in a calculator agent to total a grocery list. - - Verifies (by direct REST query) that the calculator's tool ran, the - total was reported, and the calculator was removed afterward. - """ - # Reusing allocator (cached by name): both multi-agent tests share one - # room. Starts with Agent A + User; Agent B joins during the flow. - room_id, _user_id, _user_name = await e2e_room_allocator("agno_multi_agent") - agent_a_id, agent_a_name = e2e_agent_info - agent_b_id, agent_b_name = e2e_agent_info_2 - run_id = uuid.uuid4().hex[:6] - # Long wait: A must invite B, B must run + reply, A must relay + remove. - flow_timeout = min(float(e2e_config.e2e_timeout) * 3, 100.0) - - log_banner(f"Scenario 1: assistant invites calculator (run {run_id})") - log_step( - 1, f"cast: {agent_a_name} (assistant) + {agent_b_name} (calculator) + user" - ) - - assistant = build_assistant_adapter( - e2e_config, - calculator_id=agent_b_id, - calculator_name=agent_b_name, - ) - calculator = create_calculator_agno_adapter(e2e_config) - - async with ( - running_agent( - assistant, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ), - running_agent( - calculator, - agent_id=e2e_config.test_agent_id_2, - api_key=e2e_config.band_api_key_2, - config=e2e_config, - ), - ): - log_step( - 2, - f"user → {agent_a_name}: grocery list [{grocery_list_text()}], " - f"asks for total (expect ${GROCERY_TOTAL:.2f})", - ) - prompt = ( - f"(run {run_id}) Here is my grocery list with prices: " - f"{grocery_list_text()}. What's the total? You can't do math " - "yourself, so bring in the calculator agent to add it up, then " - "remove them once you have the answer." - ) - # Wait until the calculator posts its total (a text message from B). - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_b_id, - timeout=flow_timeout, - ) as wait_for_calculator: - await send_trigger_message( - api_client, room_id, prompt, agent_a_name, agent_a_id - ) - calc_messages = await wait_for_calculator() - - log_step( - 3, - f"{agent_b_name} replied ({len(calc_messages)} msg); verifying via REST", - ) - # Primary: the calculator's add_numbers tool actually ran. - await assert_calculator_ran(e2e_session_client_2, room_id) - # Secondary: the total reached the room. - await assert_total_reported(e2e_session_client, room_id) - - log_step(4, f"checking {agent_a_name} removed {agent_b_name}") - removed = await wait_participant_absent( - e2e_session_client, room_id, agent_b_id, timeout=flow_timeout / 2 - ) - assert removed, ( - f"Calculator agent {agent_b_name} ({agent_b_id}) was still a " - f"participant of room {room_id} after the flow completed; the " - "assistant did not remove it." - ) - log_step("assert", "calculator removed from room") - - log_banner(f"Scenario 1 PASSED (run {run_id})") - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - @pytest.mark.parametrize("restart_target", ["A", "B", "both"]) - async def test_multi_agent_survives_restart( - self, - restart_target: str, - e2e_config: E2ESettings, - e2e_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_agent_info_2: tuple[str, str], - e2e_session_client: AsyncRestClient, - e2e_session_client_2: AsyncRestClient, - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - """Same flow with an agent killed and restarted mid-conversation. - - The restarted agent must rehydrate prior conversation from platform - history (``is_session_bootstrap``) and continue correctly. - - - target ``A``: restart the assistant after it has the grocery list, - before it computes the total. A must recall the list post-restart. - - target ``B``: restart the calculator after it has joined and summed - once, then have it recompute. B must rehydrate the conversation. - - target ``both``: restart A (then continue) and later B. - """ - # Reusing allocator (cached by name): both multi-agent tests share one - # room. Starts with Agent A + User; Agent B joins during the flow. - room_id, _user_id, _user_name = await e2e_room_allocator("agno_multi_agent") - agent_a_id, agent_a_name = e2e_agent_info - agent_b_id, agent_b_name = e2e_agent_info_2 - run_id = uuid.uuid4().hex[:6] - turn_timeout = min(float(e2e_config.e2e_timeout) * 3, 100.0) - - log_banner(f"Scenario 2: restart={restart_target} (run {run_id})") - - def build_assistant(): - return build_assistant_adapter( - e2e_config, - calculator_id=agent_b_id, - calculator_name=agent_b_name, - ) - - # --- Turn 1: establish the grocery list with the assistant only --- - log_step( - 1, - f"turn 1 — user → {agent_a_name}: grocery list " - f"[{grocery_list_text()}] (no total yet)", - ) - async with running_agent( - build_assistant(), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_a_id, - timeout=turn_timeout, - raise_on_timeout=True, - ) as wait_a: - await send_trigger_message( - api_client, - room_id, - ( - f"(run {run_id}) Here is my grocery list with prices: " - f"{grocery_list_text()}. Just confirm you've noted it — " - "do NOT total it yet and do NOT bring in anyone else." - ), - agent_a_name, - agent_a_id, - ) - await wait_a() - - # Turn 1's agent has stopped (context exited). For an A/both restart the - # fresh instance below must rehydrate the list purely from platform - # history; for a B restart it's effectively the same first start. - if restart_target in ("A", "both"): - log_step("restart", f"{agent_a_name} (assistant) killed → restarting") - await asyncio.sleep(_RESTART_RECONNECT_DELAY_S) - - # --- Turn 2: ask for the total; assistant brings in the calculator --- - log_step( - 2, - f"turn 2 — user → {agent_a_name}: total please; " - f"{agent_a_name} invites {agent_b_name}", - ) - async with ( - running_agent( - build_assistant(), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ), - running_agent( - create_calculator_agno_adapter(e2e_config), - agent_id=e2e_config.test_agent_id_2, - api_key=e2e_config.band_api_key_2, - config=e2e_config, - ), - ): - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_b_id, - timeout=turn_timeout, - ) as wait_b: - await send_trigger_message( - api_client, - room_id, - ( - "What's the total of my grocery list? Bring in the " - "calculator agent to add up the prices I gave you." - ), - agent_a_name, - agent_a_id, - ) - await wait_b() - - log_step(3, f"verifying {agent_b_name} ran add_numbers + total via REST") - await assert_calculator_ran(e2e_session_client_2, room_id) - await assert_total_reported(e2e_session_client, room_id) - - if restart_target in ("B", "both"): - # B stays a participant; restart only its process below. - assert await participant_present( - e2e_session_client, room_id, agent_b_id - ), "Calculator should be a participant before its restart" - - # --- Turn 3 (B / both): restart the calculator, then recompute --- - if restart_target in ("B", "both"): - log_step("restart", f"{agent_b_name} (calculator) killed → restarting") - await asyncio.sleep(_RESTART_RECONNECT_DELAY_S) - log_step( - 4, - f"turn 3 — user → {agent_a_name}: ask {agent_b_name} to recompute", - ) - async with ( - running_agent( - build_assistant(), - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ), - running_agent( - create_calculator_agno_adapter(e2e_config), - agent_id=e2e_config.test_agent_id_2, - api_key=e2e_config.band_api_key_2, - config=e2e_config, - ), - ): - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_b_id, - timeout=turn_timeout, - ) as wait_b2: - await send_trigger_message( - api_client, - room_id, - ( - "Please ask the calculator agent to add up my " - "grocery prices once more and report the total." - ), - agent_a_name, - agent_a_id, - ) - await wait_b2() - - log_step(5, f"verifying restarted {agent_b_name} recomputed via REST") - # A fresh add_numbers tool_call proves B rehydrated and re-ran. - await assert_calculator_ran(e2e_session_client_2, room_id) - await assert_total_reported(e2e_session_client, room_id) - - log_banner(f"Scenario 2 PASSED restart={restart_target} (run {run_id})") diff --git a/tests/e2e/scenarios/agno/test_thoughts.py b/tests/e2e/scenarios/agno/test_thoughts.py deleted file mode 100644 index 0b42bfc7a..000000000 --- a/tests/e2e/scenarios/agno/test_thoughts.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Agno thought-emission E2E test against the live Band platform. - -The generic smoke and tool-execution tests run for Agno via the parametrized -suite in ``adapters/test_all_adapters.py``. This module covers behavior unique -to the Agno adapter: emitting agent reasoning as ``thought`` events when -``Emit.THOUGHTS`` is enabled. - -Observability note (verified against the live platform): agent-emitted events -(``thought``, ``tool_call``, ``tool_result``) are returned by the -``agent_api_context`` REST endpoint but are NOT delivered over the user's -WebSocket ``message_created`` stream, which carries only ``text``. So this test -synchronizes on the agent's ``text`` reply over the socket, then asserts the -``thought`` event via a direct REST query. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest \ - tests/e2e/scenarios/agno/test_thoughts.py -v -s --no-cov --log-cli-level=INFO -""" - -from __future__ import annotations - -import logging - -import pytest -from band_rest import AsyncRestClient - -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - listening_for_room_activity, - log_banner, - log_step, - running_agent, - send_trigger_message, -) -from tests.e2e.scenarios.agno.conftest import ( - assert_thought_emitted, - build_thinking_adapter, -) - -logger = logging.getLogger(__name__) - - -@pytest.mark.asyncio -@requires_e2e -class TestAgnoThoughts: - """Verify the Agno adapter emits reasoning as thought events.""" - - @pytest.mark.flaky(reruns=2) - async def test_agent_emits_thought_events( - self, - e2e_config: E2ESettings, - e2e_fresh_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_session_client: AsyncRestClient, - ws_client: TrackingWebSocketClient, - api_client: AsyncRestClient, - ) -> None: - """A reasoning Agno agent posts at least one ``thought`` event. - - Synchronizes on the agent's text reply over WebSocket (the reliable - "turn finished" signal), then asserts the thought event via REST. - """ - # Fresh room (not the reusing allocator): a reused room's accumulated - # repeats of this question let Claude skip extended thinking, leaving - # reasoning_content empty so no thought is emitted. - room_id, _user_id, _user_name = await e2e_fresh_room_allocator("agno_thoughts") - agent_id, agent_name = e2e_agent_info - timeout = min(float(e2e_config.e2e_timeout) * 2, 90.0) - - log_banner("Scenario 3: Agno thought emission") - log_step(1, f"starting reasoning agent {agent_name}") - - adapter = build_thinking_adapter(e2e_config) - - async with running_agent( - adapter, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - config=e2e_config, - ): - log_step(2, "asking a question that requires step-by-step reasoning") - # Wait for the agent's text reply (events don't arrive over WS). - async with listening_for_room_activity( - ws_client, - room_id, - message_types=("text",), - sender_id=agent_id, - timeout=timeout, - raise_on_timeout=True, - ) as wait_for_reply: - await send_trigger_message( - api_client, - room_id, - ( - "If a basket has 3 apples and I add 2 more bags with 4 " - "apples each, how many apples are there in total? Think " - "it through step by step, then give the number." - ), - agent_name, - agent_id, - ) - await wait_for_reply() - - log_step(3, "agent replied; verifying a thought event via REST") - await assert_thought_emitted(e2e_session_client, room_id) - - log_banner("Scenario 3 PASSED") diff --git a/tests/e2e/scenarios/test_context_persistence.py b/tests/e2e/scenarios/test_context_persistence.py index 6fe9e2718..43af3a2ea 100644 --- a/tests/e2e/scenarios/test_context_persistence.py +++ b/tests/e2e/scenarios/test_context_persistence.py @@ -23,7 +23,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.settings import E2ESettings, requires_e2e +from tests.e2e.conftest import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, @@ -59,19 +59,6 @@ async def test_agent_remembers_context_after_rejoin( when sharing a room across parametrized runs. """ adapter_name, factory = adapter_entry - # Agno is excluded from this shared "secret code" recall test. Two of its - # behaviors break the test's assumptions in ways that are not SDK defects: - # (1) Claude-haiku refuses to act on Band's @[[id]]-formatted rehydrated - # history, treating it as injected directives ("each request stands on - # its own"); and (2) a "secret code" prompt collides with standing - # organization-scoped agent memories phrased as a "code name", so the - # agent recalls the wrong value. Agno context persistence is covered - # separately in tests/e2e/scenarios/agno/test_context_persistence.py, - # which uses a fresh room and a benign payload to avoid those effects. - if adapter_name == "agno": - pytest.skip( - "agno covered by tests/e2e/scenarios/agno/test_context_persistence.py" - ) chat_id, _user_id, _user_name = e2e_adapter_room agent_id, agent_name = e2e_agent_info timeout = e2e_config.e2e_timeout diff --git a/tests/e2e/scenarios/test_langgraph_restart_rehydration.py b/tests/e2e/scenarios/test_langgraph_restart_rehydration.py index 089d273fc..1579aef2c 100644 --- a/tests/e2e/scenarios/test_langgraph_restart_rehydration.py +++ b/tests/e2e/scenarios/test_langgraph_restart_rehydration.py @@ -33,7 +33,7 @@ from band import Agent from band.adapters import LangGraphAdapter from band.client.streaming import MessageCreatedPayload, WebSocketClient -from tests.e2e.settings import requires_e2e, requires_openai +from tests.e2e.conftest import requires_e2e, requires_openai logger = logging.getLogger(__name__) diff --git a/tests/e2e/scenarios/test_noisy_busy_room.py b/tests/e2e/scenarios/test_noisy_busy_room.py deleted file mode 100644 index 0e0a67b98..000000000 --- a/tests/e2e/scenarios/test_noisy_busy_room.py +++ /dev/null @@ -1,251 +0,0 @@ -"""E2E test for an agent in a noisy, busy, multi-party room. - -The room-isolation scenario deliberately uses *fresh* rooms so accumulated -history can't bloat rehydration into timeouts. This scenario covers the -opposite case: a room that is genuinely noisy — three participants and a burst -of chatter, most of it addressed to *someone else* — and verifies the agent -still behaves correctly. - -Two properties are checked together, for every adapter: - -1. Needle-in-haystack recall — a target fact ("project id") is seeded, then - buried under distractor chatter carrying decoy values. When asked, the agent - must recall the seeded fact, not a decoy, and must not time out on the busy - history. -2. Selective silence — the distractor chatter is addressed to other - participants. The preprocessor delivers every room message to the agent - (it only filters the agent's own messages), so the agent runs an inference - per message but must stay silent on chatter not directed at it. - -The silence check uses a *liveness probe* rather than waiting for "no answer" -(which can't tell silent-on-purpose from slow/dead): after the noise we ask the -agent an unrelated direct question. Because a room's messages are processed in -order, the probe answer arriving proves the agent already worked past every -noise message — so if it had replied to any, that reply would have arrived -first. Collecting every reply from the flood through the probe answer makes the -*count* meaningful: exactly one (the probe answer) means it stayed silent. - -Run with: - E2E_TESTS_ENABLED=true uv run pytest tests/e2e/scenarios/test_noisy_busy_room.py -v -s --no-cov -""" - -from __future__ import annotations - -import logging -import uuid - -import pytest -from band_rest import AsyncRestClient -from band_rest.types import ParticipantRequest - -from band.agent import Agent - -from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e -from tests.e2e.helpers import ( - TrackingWebSocketClient, - assert_content_contains, - assert_no_content_contains, - listening_for_agent_responses, - listening_for_room_activity, - log_banner, - log_step, - send_agent_message, - send_trigger_message, -) - -logger = logging.getLogger(__name__) - - -@pytest.mark.asyncio -@requires_e2e -class TestNoisyBusyRoom: - """An agent must recall the right fact and stay silent on cross-talk.""" - - @pytest.mark.flaky(reruns=2) - @pytest.mark.timeout(300) - async def test_recall_and_silence_in_noisy_room( - self, - e2e_config: E2ESettings, - ws_client: TrackingWebSocketClient, - adapter_entry: tuple[str, AdapterFactory], - api_client: AsyncRestClient, - e2e_fresh_room_allocator: RoomAllocator, - e2e_agent_info: tuple[str, str], - e2e_session_client_2: AsyncRestClient, - e2e_agent_info_2: tuple[str, str], - ): - """Recall a buried fact and ignore chatter addressed to others. - - Wording note: the seeded fact is a neutral "project id", not a "secret - code" — models refuse to repeat back a credential-shaped value, a false - failure unrelated to what this test checks. - """ - adapter_name, factory = adapter_entry - agent_id, agent_name = e2e_agent_info - agent_2_id, agent_2_name = e2e_agent_info_2 - timeout = e2e_config.e2e_timeout - # The agent processes the room's messages one at a time, so the probe - # answer only arrives after it has chewed through every noise message. - # Give that window room for several sequential inferences. - flood_timeout = timeout * 3 - - # Per-run tokens so cross-run history can't make an assertion pass (or - # fail) by coincidence; adapter-prefixed to keep the transcript clear. - # Decoy stems are distinct whole words (not single letters) so none can - # be a substring of the needle — e.g. the needle ends in "...ANTHROPIC_ - # ", which a "C_" decoy would falsely match. - run_id = uuid.uuid4().hex[:6] - needle = f"PROJECT_{adapter_name.upper()}_{run_id}" - weather = f"WEATHER_{run_id}" - color = f"COLOR_{run_id}" - build = f"BUILD_{run_id}" - decoys = (weather, color, build) - live = f"LIVE_{run_id}" - - log_banner(f"[{adapter_name}] Noisy busy room — recall + selective silence") - - # --- Phase 1: multi-party room (agent + user + agent_2) --- - room_id, user_id, user_name = await e2e_fresh_room_allocator("noisy-room") - await api_client.human_api_participants.add_my_chat_participant( - chat_id=room_id, - participant=ParticipantRequest(participant_id=agent_2_id, role="member"), - ) - parts = await api_client.human_api_participants.list_my_chat_participants( - room_id - ) - part_ids = {p.id for p in (parts.data or [])} - assert {agent_id, user_id, agent_2_id} <= part_ids, ( - f"[{adapter_name}] expected a multi-party room with agent, user and " - f"agent_2; participants were {part_ids}" - ) - log_step( - 1, - f"room={room_id} participants=[agent={agent_name}, user={user_name}, " - f"agent_2={agent_2_name}]", - ) - - adapter = factory(e2e_config) - agent = Agent.create( - adapter=adapter, - agent_id=e2e_config.test_agent_id, - api_key=e2e_config.band_api_key, - ws_url=e2e_config.band_ws_url, - rest_url=e2e_config.band_base_url, - ) - - async with agent: - # --- Phase 2: seed the needle (addressed to our agent) --- - async with listening_for_agent_responses( - ws_client, room_id, timeout=timeout, raise_on_timeout=True - ) as wait: - await send_trigger_message( - api_client, - room_id, - f"Please note for later — the project id is {needle}. " - "Just acknowledge.", - agent_name, - agent_id, - ) - ack = await wait() - assert len(ack) >= 1, ( - f"[{adapter_name}] agent never acknowledged the seeded fact" - ) - log_step(2, f"seeded needle={needle}; agent acked ({len(ack)} msg)") - - # --- Phase 3: flood with noise addressed to OTHERS, then probe --- - # min_messages is set above any plausible count so the window ends - # only when the probe answer (sentinel `live`) arrives — letting us - # count every reply the agent made meanwhile. - async with listening_for_room_activity( - ws_client, - room_id, - timeout=flood_timeout, - message_types=("text",), - sender_id=agent_id, - min_messages=99, - stop_substring=live, - raise_on_timeout=True, - ) as wait: - await send_trigger_message( - api_client, - room_id, - f"FYI the weather token is {weather}.", - agent_2_name, - agent_2_id, - ) - await send_agent_message( - e2e_session_client_2, - room_id, - f"Thanks. For the record, the color code is {color}.", - user_name, - user_id, - ) - await send_trigger_message( - api_client, - room_id, - f"Got it. Also the build number is {build}.", - agent_2_name, - agent_2_id, - ) - await send_agent_message( - e2e_session_client_2, - room_id, - "Acknowledged, nothing further.", - user_name, - user_id, - ) - # Unrelated direct question — the liveness probe. - await send_trigger_message( - api_client, - room_id, - f"Reply with just the word {live} and nothing else.", - agent_name, - agent_id, - ) - during_noise = await wait() - - contents = [m.content for m in during_noise] - log_step( - 3, - f"posted 4 noise msgs (decoys {weather}/{color}/{build}); probe={live}", - ) - # Liveness: the probe was answered, so the agent is alive and has - # processed past all the noise. - assert_content_contains(during_noise, live) - # Selective silence: the probe answer is the ONLY thing it said. Any - # reply to the addressed-to-others noise would be an extra entry. - assert len(during_noise) == 1, ( - f"[{adapter_name}] agent should have spoken exactly once (the " - f"probe answer) but said {len(during_noise)}: {contents} — it " - "replied to chatter addressed to other participants" - ) - log_step("assert", f"silent on cross-talk; replies={contents}") - - # --- Phase 4: recall the buried needle (addressed to our agent) --- - async with listening_for_agent_responses( - ws_client, room_id, timeout=timeout, raise_on_timeout=True - ) as wait: - await send_trigger_message( - api_client, - room_id, - "What is the project id? Reply with just it.", - agent_name, - agent_id, - ) - recall = await wait() - assert len(recall) >= 1, ( - f"[{adapter_name}] agent never answered the recall question" - ) - assert_content_contains(recall, needle) - for decoy in decoys: - assert_no_content_contains(recall, decoy) - log_step( - 4, - f"recall reply={[m.content for m in recall]}; " - f"found {needle}, no decoys", - ) - - log_banner( - f"[{adapter_name}] PASSED: busy room — correct recall + selective silence" - ) diff --git a/tests/e2e/scenarios/test_room_isolation.py b/tests/e2e/scenarios/test_room_isolation.py index 2a0ac80b0..6ab837b3f 100644 --- a/tests/e2e/scenarios/test_room_isolation.py +++ b/tests/e2e/scenarios/test_room_isolation.py @@ -23,7 +23,7 @@ from band.agent import Agent from tests.e2e.adapters.conftest import AdapterFactory -from tests.e2e.settings import E2ESettings, RoomAllocator, requires_e2e +from tests.e2e.conftest import E2ESettings, requires_e2e from tests.e2e.helpers import ( TrackingWebSocketClient, assert_content_contains, @@ -47,31 +47,29 @@ async def test_agents_in_different_rooms_isolated( ws_client: TrackingWebSocketClient, adapter_entry: tuple[str, AdapterFactory], api_client: AsyncRestClient, - e2e_fresh_room_allocator: RoomAllocator, + e2e_adapter_room: tuple[str, str, str], + e2e_isolation_room_b: tuple[str, str, str], e2e_agent_info: tuple[str, str], ): """Agents in different rooms don't see each other's context. - Room A: Send "Remember this note: " - Room B: Send "Remember this note: " - Room A: Ask "What was the note?" -> Assert unique_a, not unique_b - Room B: Ask "What was the note?" -> Assert unique_b, not unique_a + Room A (adapter's dedicated room): Send "The code is " + Room B (shared isolation room): Send "The code is " + Room A: Ask "What's the code?" -> Assert unique_a, not unique_b + Room B: Ask "What's the code?" -> Assert unique_b, not unique_a - Wording note: the payload is framed as a "note", not a "secret code". - Models reliably refuse to repeat back a "code" (it reads as a credential), - which is unrelated to isolation; a neutral noun avoids that false failure. - - Uses fresh rooms per run (via e2e_fresh_room_allocator) so no stale - history accumulates — otherwise a reused room bloats the rehydrated - context into timeouts. Unique per-run keywords additionally guard against - any cross-run confusion. + Uses unique keywords per adapter+run to avoid cross-adapter and + cross-run contamination in shared rooms that persist across sessions. + Note: Room B is shared across all adapters; stale history accumulates + across runs. If LLMs start confusing old codes with new ones, prune + the room or create a fresh agent. """ adapter_name, factory = adapter_entry timeout = e2e_config.e2e_timeout agent_id, agent_name = e2e_agent_info - # Distinct keywords per room so the cross-room assertions can't pass by - # coincidence; per-adapter/run suffix keeps logs unambiguous. + # Unique keywords per adapter AND per run to prevent stale history + # from confusing the LLM in rooms that persist across test sessions. run_id = uuid.uuid4().hex[:6] code_a = f"ALPHA_{adapter_name.upper()}_{run_id}" code_b = f"BRAVO_{adapter_name.upper()}_{run_id}" @@ -83,8 +81,8 @@ async def test_agents_in_different_rooms_isolated( code_b, ) - room_a_id, _ua, _na = await e2e_fresh_room_allocator("room-isolation-a") - room_b_id, _ub, _nb = await e2e_fresh_room_allocator("room-isolation-b") + room_a_id, _user_id, _user_name = e2e_adapter_room + room_b_id = e2e_isolation_room_b[0] logger.info("Room A: %s, Room B: %s", room_a_id, room_b_id) # Create adapter and agent (single agent, two rooms) @@ -107,7 +105,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_a_id, - f"Remember this note: {code_a}. Confirm you remember it.", + f"Remember: the secret code is {code_a}. Confirm you remember it.", agent_name, agent_id, ) @@ -119,7 +117,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_b_id, - f"Remember this note: {code_b}. Confirm you remember it.", + f"Remember: the secret code is {code_b}. Confirm you remember it.", agent_name, agent_id, ) @@ -139,7 +137,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_a_id, - "What was the note? Reply with just it.", + "What is the secret code? Reply with just the code word.", agent_name, agent_id, ) @@ -151,7 +149,7 @@ async def test_agents_in_different_rooms_isolated( await send_trigger_message( api_client, room_b_id, - "What was the note? Reply with just it.", + "What is the secret code? Reply with just the code word.", agent_name, agent_id, ) diff --git a/tests/e2e/settings.py b/tests/e2e/settings.py deleted file mode 100644 index dfb9319a7..000000000 --- a/tests/e2e/settings.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Shared E2E settings, skip markers, and types. - -Lives in a plain module (not ``conftest.py``) so fixtures, helpers, and tests can -import these symbols without importing from a conftest — which couples modules to -pytest's collection machinery and invites circular imports. ``conftest.py`` holds -only hooks and fixture registration. - -Configuration is loaded from ``.env.test`` with E2E-specific overrides from env -vars. E2E tests run adapters against a real Band platform with real (cheap) LLMs; -they verify platform/integration correctness, not LLM output quality, and run -manually only (never in CI): - - E2E_TESTS_ENABLED=true uv run pytest tests/e2e/ -v -s --no-cov -""" - -from __future__ import annotations - -import logging -import os -from collections.abc import Awaitable, Callable -from pathlib import Path - -import pytest -from dotenv import load_dotenv -from pydantic import ValidationError -from thenvoi_testing.settings import BaseTestSettings - -# Load .env.test into os.environ so LLM libraries (langchain, anthropic, etc.) -# can pick up OPENAI_API_KEY, ANTHROPIC_API_KEY, and other keys. -_ENV_TEST_PATH = Path(__file__).parent.parent.parent / ".env.test" -load_dotenv(_ENV_TEST_PATH, override=False) - -logger = logging.getLogger(__name__) - -# Async callable: name -> (room_id, user_id, user_name). Shared by room fixtures -# and by tests that accept an allocator; defined here so both can import it. -RoomAllocator = Callable[[str], Awaitable[tuple[str, str, str]]] - - -# ============================================================================= -# E2E Settings -# ============================================================================= - - -class E2ESettings(BaseTestSettings): - """Settings for E2E tests, loaded from .env.test. - - Loads from .env.test and allows E2E-specific overrides via env vars. - Pydantic BaseSettings automatically maps environment variables to fields - (e.g. E2E_LLM_MODEL -> e2e_llm_model) with case-insensitive matching. - """ - - class Config: - env_file = _ENV_TEST_PATH - - band_api_key: str = "" - band_api_key_2: str = "" - band_api_key_user: str = "" - band_base_url: str = "http://localhost:4000" - band_ws_url: str = "ws://localhost:4000/api/v1/socket/websocket" - test_agent_id: str = "" - test_agent_id_2: str = "" - - # E2E-specific settings (override via environment variables) - e2e_llm_model: str = "gpt-5.4-mini" - e2e_anthropic_model: str = "claude-haiku-4-5-20251001" - e2e_timeout: int = 30 - e2e_tests_enabled: bool = False - - -# ============================================================================= -# Skip Markers -# ============================================================================= - - -def _check_e2e_status() -> tuple[bool, str]: - """Check if E2E tests should be skipped. - - Evaluated once at module import time (when the ``requires_e2e`` marker - is created). Returns ``(is_disabled, reason)`` so the skip message is - actionable. - """ - try: - settings = E2ESettings() - if not settings.e2e_tests_enabled: - return True, "E2E_TESTS_ENABLED is not set to true" - if not settings.band_api_key: - return True, "BAND_API_KEY is not set" - return False, "E2E tests enabled" - except (ValidationError, ValueError, OSError) as exc: - logger.warning( - "E2E settings could not be loaded (missing .env.test?), skipping E2E tests", - exc_info=True, - ) - return True, f"E2E settings could not be loaded: {exc}" - - -_e2e_is_disabled, _e2e_skip_reason = _check_e2e_status() - -requires_e2e = pytest.mark.skipif( - _e2e_is_disabled, - reason=_e2e_skip_reason or "E2E tests disabled", -) - -requires_openai = pytest.mark.skipif( - not os.environ.get("OPENAI_API_KEY"), - reason="OPENAI_API_KEY not set", -) diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 29c607c04..8737a4d75 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -264,16 +264,6 @@ def _opencode_factory(**kw: Any) -> Any: return OpencodeAdapter(**kw) -def _agno_factory(**kw: Any) -> Any: - from band.adapters.agno import AgnoAdapter - - # AgnoAdapter takes a developer-built Agno Agent; inject a stand-in so the - # adapter can be constructed without a real model/API key. - if "agent" not in kw: - kw["agent"] = MagicMock() - return AgnoAdapter(**kw) - - def _gemini_factory(**kw: Any) -> Any: from band.adapters.gemini import GeminiAdapter @@ -674,27 +664,6 @@ def _build_opencode_config() -> AdapterConfig: ) -def _build_agno_config() -> AdapterConfig: - return AdapterConfig( - framework_id="agno", - display_name="Agno", - adapter_factory=_agno_factory, - # AgnoAdapter has no model/prompt of its own (the caller's Agno agent - # owns those); assert the adapter-level state instead. - expected_initial_values={ - "agent": None, # the run copy is built in on_started - # Band tools are resolved per-run via a callable factory installed in - # on_started, cached by contact-flag; nothing is cached before start. - "_band_tools_cache": {}, - }, - # No model/prompt kwargs to customize; nothing to assert here. - custom_kwargs={}, - custom_expected={}, - # AgnoAdapter does not expose Band custom tools (no additional_tools). - has_custom_tools_attr=False, - ) - - def _build_gemini_config() -> AdapterConfig: from band.adapters.gemini import GeminiAdapter @@ -776,7 +745,6 @@ def _build_google_adk_config() -> AdapterConfig: _build_codex_config, _build_letta_config, _build_opencode_config, - _build_agno_config, _build_gemini_config, _build_google_adk_config, ] diff --git a/tests/framework_configs/converters.py b/tests/framework_configs/converters.py index 34fdac32e..67703fda7 100644 --- a/tests/framework_configs/converters.py +++ b/tests/framework_configs/converters.py @@ -112,12 +112,6 @@ def _parlant_factory(**kw: Any) -> Any: return ParlantHistoryConverter(**kw) -def _agno_factory(**kw: Any) -> Any: - from band.converters.agno import AgnoHistoryConverter - - return AgnoHistoryConverter(**kw) - - def _gemini_factory(**kw: Any) -> Any: from band.converters.gemini import GeminiHistoryConverter @@ -240,24 +234,6 @@ def _build_parlant_config() -> ConverterConfig: ) -def _build_agno_config() -> ConverterConfig: - from tests.framework_configs.output_adapters import AgnoOutputAdapter - - return ConverterConfig( - framework_id="agno", - display_name="Agno", - converter_factory=_agno_factory, - empty_result=[], - # Keeps own-agent text as an assistant Message (not filtered). - filters_own_messages=False, - # Converts tool_call -> assistant tool_calls, tool_result -> tool message. - skips_tool_events=False, - empty_sender_behavior=SenderBehavior.CONTENT_AS_IS, - missing_sender_behavior=SenderBehavior.CONTENT_AS_IS, - output_adapter=AgnoOutputAdapter(), - ) - - def _build_gemini_config() -> ConverterConfig: from tests.framework_configs.output_adapters import GeminiOutputAdapter @@ -326,7 +302,6 @@ def _build_google_adk_config() -> ConverterConfig: _build_claude_sdk_config, _build_pydantic_ai_config, _build_parlant_config, - _build_agno_config, _build_gemini_config, _build_google_adk_config, ] diff --git a/tests/framework_configs/output_adapters.py b/tests/framework_configs/output_adapters.py index 9e59792be..b17c1fab7 100644 --- a/tests/framework_configs/output_adapters.py +++ b/tests/framework_configs/output_adapters.py @@ -18,7 +18,6 @@ "GoogleADKOutputAdapter", "LangChainOutputAdapter", "PydanticAIOutputAdapter", - "AgnoOutputAdapter", "GeminiOutputAdapter", "StringOutputAdapter", "SenderDictListAdapter", @@ -181,63 +180,6 @@ def assert_sender_metadata( ) -class AgnoOutputAdapter: - """Adapter for Agno converter output (list of agno Message objects).""" - - def assert_result_type(self, result: list) -> None: - assert isinstance(result, list), f"Expected list, got {type(result).__name__}" - - def result_length(self, result: list) -> int: - return len(result) - - def get_content(self, result: list, index: int) -> str: - return result[index].content or "" - - def get_role(self, result: list, index: int) -> str: - return result[index].role - - def is_empty(self, result: list) -> bool: - return len(result) == 0 - - def content_contains(self, result: list, substring: str) -> bool: - for msg in result: - if msg.content and substring in str(msg.content): - return True - if getattr(msg, "tool_name", None) and substring in msg.tool_name: - return True - for tc in getattr(msg, "tool_calls", None) or []: - fn = tc.get("function", {}) - if substring in fn.get("name", "") or substring in str( - fn.get("arguments", "") - ): - return True - return False - - def assert_element_type(self, result: list, index: int, expected_role: str) -> None: - from agno.models.message import Message - - msg = result[index] - assert isinstance(msg, Message), ( - f"Expected agno Message, got {type(msg).__name__}" - ) - assert msg.role == expected_role, ( - f"Expected role {expected_role!r}, got {msg.role!r}" - ) - - def assert_sender_metadata( - self, - result: list, - index: int, - sender_name: str, - sender_type: str | None = None, - ) -> None: - raise NotImplementedError( - "AgnoOutputAdapter.assert_sender_metadata() is not supported. " - "Agno messages do not include sender metadata. " - "Ensure has_sender_metadata=False in the ConverterConfig." - ) - - class PydanticAIOutputAdapter: """Adapter for PydanticAI converter output (list of ModelRequest/ModelResponse).""" diff --git a/tests/integrations/test_langgraph_tools.py b/tests/integrations/test_langgraph_tools.py index 8e5af7228..1efedc537 100644 --- a/tests/integrations/test_langgraph_tools.py +++ b/tests/integrations/test_langgraph_tools.py @@ -8,8 +8,11 @@ from band.core.exceptions import BandToolError from band.core.types import AdapterFeatures, Capability -from band.integrations.langgraph.langchain_tools import agent_tools_to_langchain -from band.runtime.tools import get_band_tool_category, iter_tool_definitions +from band.integrations.langgraph.langchain_tools import ( + agent_tools_to_langchain, + get_langgraph_tool_category, +) +from band.runtime.tools import iter_tool_definitions def _mock_agent_tools() -> MagicMock: @@ -93,7 +96,7 @@ def test_every_agent_tool_has_shared_category(self) -> None: include_memory=True, include_contacts=True, ) - if get_band_tool_category(definition.name) is None + if get_langgraph_tool_category(definition.name) is None ] assert missing == [] diff --git a/tests/runtime/test_tools.py b/tests/runtime/test_tools.py index 0fb11ba01..6ba7a0fbb 100644 --- a/tests/runtime/test_tools.py +++ b/tests/runtime/test_tools.py @@ -991,23 +991,6 @@ def test_get_tool_schemas_anthropic_with_memory(self, mock_rest_client): assert "band_send_message" in tool_names assert "band_list_contacts" in tool_names - def test_schemas_drop_numeric_bounds(self, mock_rest_client): - """Pydantic Field(ge=.., le=..) renders minimum/maximum, which some - providers reject on integer params; the schemas must omit them while the - models still enforce the bounds at execution.""" - tools = AgentTools("room-123", mock_rest_client) - - schemas = tools.get_tool_schemas("openai", include_memory=True) - - page_size = next( - s["function"]["parameters"]["properties"]["page_size"] - for s in schemas - if s["function"]["name"] == "band_lookup_peers" - ) - assert "minimum" not in page_size - assert "maximum" not in page_size - assert page_size["type"] == "integer" - class TestAgentToolsExecuteToolCall: """Test execute_tool_call dispatch.""" diff --git a/uv.lock b/uv.lock index 06d49d756..fcddb8d59 100644 --- a/uv.lock +++ b/uv.lock @@ -135,33 +135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] -[[package]] -name = "agno" -version = "2.6.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "gitpython" }, - { name = "h11" }, - { name = "httpx", extra = ["http2"] }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pydantic-settings", version = "2.10.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, - { name = "pydantic-settings", version = "2.13.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "python-dotenv" }, - { name = "python-multipart" }, - { name = "pyyaml" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "typer", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, - { name = "typer", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/08/99d70ea99b95aaeb0e98c1cffa6c19ee11fd43c919a74bc020ec671c18b7/agno-2.6.16.tar.gz", hash = "sha256:cc938d16e4ab0dcf3bd97c40867908eb8cefe034e6ee2082b3398cf01e1913bd", size = 2148053, upload-time = "2026-06-15T20:56:30.708Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f0/630d3ebaa44b5f61b2a11eb22a81231eea22650b3fd04583d57f0fe1587b/agno-2.6.16-py3-none-any.whl", hash = "sha256:86a0e1090d34aa3248983dbd57a277a9124c1a7f2e065dadc45f15e8dc9eacb0", size = 2536449, upload-time = "2026-06-15T20:56:28.641Z" }, -] - [[package]] name = "aiofile" version = "3.9.0" @@ -537,9 +510,6 @@ agentcore-runtime = [ { name = "fastapi" }, { name = "uvicorn" }, ] -agno = [ - { name = "agno" }, -] anthropic = [ { name = "anthropic" }, ] @@ -569,7 +539,6 @@ crewai = [ dev = [ { name = "a2a-sdk" }, { name = "agent-client-protocol" }, - { name = "agno" }, { name = "aiohttp" }, { name = "anthropic" }, { name = "beautifulsoup4" }, @@ -602,7 +571,6 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-timeout" }, { name = "python-dotenv" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, { name = "ruff" }, { name = "slack-sdk" }, { name = "starlette" }, @@ -624,7 +592,6 @@ dev-crewai = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-timeout" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" } }, { name = "ruff" }, { name = "thenvoi-testing-python" }, ] @@ -675,8 +642,6 @@ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'dev'", specifier = ">=0.3.22" }, { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0" }, { name = "agent-client-protocol", marker = "extra == 'dev'", specifier = ">=0.9.0" }, - { name = "agno", marker = "extra == 'agno'", specifier = ">=2.6.0" }, - { name = "agno", marker = "extra == 'dev'", specifier = ">=2.6.0" }, { name = "aiohttp", marker = "extra == 'bridge'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'bridge-agentcore'", specifier = ">=3.9,<4" }, { name = "aiohttp", marker = "extra == 'dev'", specifier = ">=3.9,<4" }, @@ -764,8 +729,6 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'dev'", specifier = ">=1.2.2" }, { name = "python-multipart", marker = "extra == 'a2a-gateway'", specifier = ">=0.0.22" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "rich", marker = "extra == 'dev'", specifier = ">=13.0.0" }, - { name = "rich", marker = "extra == 'dev-crewai'", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev-crewai'", specifier = ">=0.8.0" }, { name = "slack-sdk", marker = "extra == 'dev'", specifier = ">=3.27.0" }, @@ -787,7 +750,7 @@ requires-dist = [ { name = "werkzeug", marker = "extra == 'dev'", specifier = ">=3.1.6" }, { name = "werkzeug", marker = "extra == 'parlant'", specifier = ">=3.1.6" }, ] -provides-extras = ["codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "agno", "dev", "dev-crewai"] +provides-extras = ["codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "dev", "dev-crewai"] [[package]] name = "bcrypt" @@ -2083,30 +2046,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - [[package]] name = "google-adk" version = "1.10.0" @@ -2998,19 +2937,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, -] - [[package]] name = "hf-xet" version = "1.4.3" @@ -3043,15 +2969,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] -[[package]] -name = "hpack" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -3128,11 +3045,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[package.optional-dependencies] -http2 = [ - { name = "h2" }, -] - [[package]] name = "httpx-sse" version = "0.4.3" @@ -3175,15 +3087,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] -[[package]] -name = "hyperframe" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, -] - [[package]] name = "identify" version = "2.6.19" @@ -4338,18 +4241,12 @@ resolution-markers = [ "python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version == '3.13.*' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version < '3.13' and extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] dependencies = [ - { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra != 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai')" }, + { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -7881,71 +7778,20 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "pygments", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pygments", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ @@ -8298,15 +8144,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "sniffio" version = "1.3.1" @@ -8703,72 +8540,21 @@ name = "typer" version = "0.24.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version >= '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version == '3.13.*' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", - "python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "annotated-doc", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, - { name = "shellingham", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, + { name = "annotated-doc", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "shellingham", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ From 3b9bab91d959455d6f879d38e3e6a26f92f306b8 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Sun, 28 Jun 2026 10:27:46 +0300 Subject: [PATCH 89/90] Revert "feat: raise a visible UserWarning for unsupported adapter feature flags" --- src/band/core/simple_adapter.py | 39 +++++++++------------- tests/core/test_simple_adapter_features.py | 18 +++------- tests/integrations/slack/test_wrapping.py | 7 +--- 3 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/band/core/simple_adapter.py b/src/band/core/simple_adapter.py index 768039d5d..966070405 100644 --- a/src/band/core/simple_adapter.py +++ b/src/band/core/simple_adapter.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import warnings from abc import ABC, abstractmethod from typing import Any, ClassVar, Generic, TypeVar, cast @@ -22,21 +21,6 @@ H = TypeVar("H") -def _warn_unsupported( - adapter_name: str, - kind: str, - unsupported: frozenset[Emit] | frozenset[Capability], -) -> None: - """Log and emit a UserWarning naming feature flags an adapter ignores.""" - message = ( - f"{adapter_name} does not support {kind} values: " - f"{', '.join(sorted(v.value for v in unsupported))} " - "(they will have no effect)" - ) - logger.warning(message) - warnings.warn(message, UserWarning, stacklevel=3) - - class SimpleAdapter(Generic[H], ABC): """ Simple base class for framework adapters. @@ -46,7 +30,7 @@ class SimpleAdapter(Generic[H], ABC): Subclasses should declare SUPPORTED_EMIT and SUPPORTED_CAPABILITIES as class-level sets to document what they actually implement. - on_started() logs and emits a UserWarning for unsupported values. + on_started() will warn if features request unsupported values. Example: class MyAdapter(SimpleAdapter[list[ChatMessage]]): @@ -128,12 +112,21 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: self.agent_name = agent_name self.agent_description = agent_description - # Warn on unsupported feature values. - name = type(self).__name__ - if unsupported_emit := self.features.emit - self.SUPPORTED_EMIT: - _warn_unsupported(name, "emit", unsupported_emit) - if unsupported_caps := self.features.capabilities - self.SUPPORTED_CAPABILITIES: - _warn_unsupported(name, "capability", unsupported_caps) + # Warn on unsupported feature values + unsupported_emit = self.features.emit - self.SUPPORTED_EMIT + if unsupported_emit: + logger.warning( + "%s does not support emit values: %s (they will have no effect)", + type(self).__name__, + ", ".join(sorted(e.value for e in unsupported_emit)), + ) + unsupported_caps = self.features.capabilities - self.SUPPORTED_CAPABILITIES + if unsupported_caps: + logger.warning( + "%s does not support capability values: %s (they will have no effect)", + type(self).__name__, + ", ".join(sorted(c.value for c in unsupported_caps)), + ) # Propagate agent name to converter if it supports it if self.history_converter and hasattr(self.history_converter, "set_agent_name"): diff --git a/tests/core/test_simple_adapter_features.py b/tests/core/test_simple_adapter_features.py index dc4bec318..c752ab4d7 100644 --- a/tests/core/test_simple_adapter_features.py +++ b/tests/core/test_simple_adapter_features.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import warnings from typing import Any import pytest @@ -89,8 +88,7 @@ async def test_warns_on_unsupported_emit( features=AdapterFeatures(emit={Emit.EXECUTION, Emit.THOUGHTS}), ) with caplog.at_level(logging.WARNING): - with pytest.warns(UserWarning, match="does not support emit values"): - await adapter.on_started("test-agent", "A test agent") + await adapter.on_started("test-agent", "A test agent") assert "does not support emit values" in caplog.text assert "THOUGHTS" in caplog.text or "thoughts" in caplog.text @@ -104,8 +102,7 @@ async def test_warns_on_unsupported_capabilities( ), ) with caplog.at_level(logging.WARNING): - with pytest.warns(UserWarning, match="does not support capability values"): - await adapter.on_started("test-agent", "A test agent") + await adapter.on_started("test-agent", "A test agent") assert "does not support capability values" in caplog.text assert "CONTACTS" in caplog.text or "contacts" in caplog.text @@ -119,9 +116,7 @@ async def test_no_warning_when_supported( ), ) with caplog.at_level(logging.WARNING): - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - await adapter.on_started("test-agent", "A test agent") + await adapter.on_started("test-agent", "A test agent") assert "does not support" not in caplog.text @pytest.mark.asyncio @@ -130,9 +125,7 @@ async def test_no_warning_on_empty_features( ) -> None: adapter = _TestAdapter() with caplog.at_level(logging.WARNING): - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - await adapter.on_started("test-agent", "A test agent") + await adapter.on_started("test-agent", "A test agent") assert "does not support" not in caplog.text @pytest.mark.asyncio @@ -144,7 +137,6 @@ async def test_bare_adapter_no_warning( features=AdapterFeatures(emit={Emit.EXECUTION}), ) with caplog.at_level(logging.WARNING): - with pytest.warns(UserWarning, match="does not support emit values"): - await adapter.on_started("test-agent", "A test agent") + await adapter.on_started("test-agent", "A test agent") # _BareAdapter has empty SUPPORTED_EMIT, so EXECUTION is unsupported assert "does not support emit values" in caplog.text diff --git a/tests/integrations/slack/test_wrapping.py b/tests/integrations/slack/test_wrapping.py index f826fc35a..0426b0f4e 100644 --- a/tests/integrations/slack/test_wrapping.py +++ b/tests/integrations/slack/test_wrapping.py @@ -21,7 +21,6 @@ import hmac import json import time -import warnings from datetime import datetime, timezone from types import SimpleNamespace from typing import Any @@ -296,11 +295,7 @@ async def test_on_started_mirrors_inner_support_no_spurious_warning(caplog): adapter, _, _, _ = _make_adapter(inner=inner) with caplog.at_level("WARNING"): - with warnings.catch_warnings(): - # A spurious UserWarning here would mean the wrapper failed to - # mirror the inner's support before the base check ran. - warnings.simplefilter("error", UserWarning) - await adapter.on_started("MyBot", "") + await adapter.on_started("MyBot", "") # Wrapper now reflects the inner's declared support. assert adapter.SUPPORTED_EMIT == frozenset({Emit.EXECUTION}) From e922a762b93f7efbe8e6f3532350f86b19e8fcad Mon Sep 17 00:00:00 2001 From: "band-public-releases-bot[bot]" <287690955+band-public-releases-bot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:47:32 +0000 Subject: [PATCH 90/90] chore(main): release band-sdk 1.2.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5fdd88304..c3f146397 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8ec0a12..18d4c9ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This changelog is automatically generated by [Release Please](https://github.com/googleapis/release-please). +## [1.2.0](https://github.com/band-ai/band-sdk-python/compare/band-sdk-v1.1.0...band-sdk-v1.2.0) (2026-06-29) + + +### Features + +* [1] bootstrap E2E testing tools and smoke tests INT-912 ([#395](https://github.com/band-ai/band-sdk-python/issues/395)) ([888ca9a](https://github.com/band-ai/band-sdk-python/commit/888ca9a2ce1d3b15a787c589c6dc32fa74aa40ce)) +* Add SDK logging configuration helpers ([#386](https://github.com/band-ai/band-sdk-python/issues/386)) ([8a39365](https://github.com/band-ai/band-sdk-python/commit/8a39365ad469aba22082b18f343005b00a23364c)) +* add Tom & Jerry character agents for Codex adapter ([#403](https://github.com/band-ai/band-sdk-python/issues/403)) ([8d981ed](https://github.com/band-ai/band-sdk-python/commit/8d981ed3beba1de954e6a73d8d5c782d2756e689)) +* raise a visible UserWarning for unsupported adapter feature flags ([0039d36](https://github.com/band-ai/band-sdk-python/commit/0039d3600b4fe4c02c1e0932010b0b53dcb438ad)) +* raise a visible userwarning for unsupported adapter feature flags int 880 ([#401](https://github.com/band-ai/band-sdk-python/issues/401)) ([b47d128](https://github.com/band-ai/band-sdk-python/commit/b47d1284ac2239e2e2c394d0d819ef734a2672be)) +* sdk add agno adapter python int 856 ([6493a9d](https://github.com/band-ai/band-sdk-python/commit/6493a9d56697477f976cc04ce63670aa289fba95)) +* sdk add agno adapter python int 856 ([#399](https://github.com/band-ai/band-sdk-python/issues/399)) ([d5552cf](https://github.com/band-ai/band-sdk-python/commit/d5552cf46d98681b4225bf73c52cc0e0a9bf52c0)) +* **sdk:** add py.typed marker to band package (PEP 561) [INT-885] ([#391](https://github.com/band-ai/band-sdk-python/issues/391)) ([ddbccc1](https://github.com/band-ai/band-sdk-python/commit/ddbccc146023aede5faccc122101f23fdd9afd03)) + + +### Bug Fixes + +* **agno:** inject Band identity into prompt; split Tom/Jerry example ([c583701](https://github.com/band-ai/band-sdk-python/commit/c58370147b4e2464511e4faf943d01310473d099)) + + +### Reverts + +* raise a visible UserWarning for unsupported adapter feature flags" ([33fd80b](https://github.com/band-ai/band-sdk-python/commit/33fd80bc3e8e9748a842fe33683de7f73cac9724)) +* sdk add agno adapter python int 856" ([922453d](https://github.com/band-ai/band-sdk-python/commit/922453dbe0940dcf2e5bfb13a88cb3f1858068fe)) + + +### Documentation + +* **agno:** decouple model provider from the agno extra ([140af95](https://github.com/band-ai/band-sdk-python/commit/140af95ef80dcced3550d3dbf212adcef2da23fc)) + ## [1.1.0](https://github.com/thenvoi/thenvoi-sdk-python/compare/band-sdk-v1.0.0...band-sdk-v1.1.0) (2026-06-22) diff --git a/pyproject.toml b/pyproject.toml index 64e3afb3b..ac2a02bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "band-sdk" -version = "1.1.0" +version = "1.2.0" description = "A Python SDK for Band API" readme = "README.md" requires-python = ">=3.11"