From 8e354ac21380bd4d4ab1ba098ba2ddbf50a617bc Mon Sep 17 00:00:00 2001 From: Nir Singher Date: Mon, 13 Jul 2026 15:15:49 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Strands=20Agents=20adapter=20?= =?UTF-8?q?=E2=80=94=20injectable-model=20family=20[INT-971]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add StrandsAdapter (SimpleAdapter[StrandsMessages]) + StrandsHistoryConverter for AWS Strands Agents (strands-agents>=1.40,<2), mirroring the pydantic-ai adapter: platform tools as native @tool functions (per-turn tools handle via invocation_state), CustomToolDef bridged through a native AgentTool, L6 tool_call/tool_result via Before/AfterToolCall hooks, per-turn TurnUsage from accumulated metrics, terminal detection via is_terminal_success. A Strands Agent is stateful (owns messages, throws on concurrent invocation), so the adapter builds prompt+tools once in on_started and constructs a lightweight per-turn Agent over band-owned per-room history — the same fresh-runner-per-message shape as the google_adk adapter. Registered across the strands/dev extras, lazy adapter import, the baseline matrix (core lane via Dep.OPENAI), and the conformance config registries; Tier-1 injection spike proves scripted-model -> real loop -> typed dispatch with ordered/paired L6 events and a negative control. ✌️ --- examples/strands/01_basic_agent.py | 69 ++ examples/strands/02_custom_tools.py | 81 ++ examples/strands/setup_logging.py | 12 + pyproject.toml | 5 + src/band/adapters/__init__.py | 7 + src/band/adapters/strands.py | 746 ++++++++++++++++++ src/band/converters/strands.py | 210 +++++ tests/adapters/test_strands_adapter.py | 412 ++++++++++ tests/converters/test_strands.py | 177 +++++ tests/e2e/baseline/toolkit/adapters.py | 1 + tests/e2e/baseline/toolkit/builders.py | 26 + tests/framework_configs/adapters.py | 44 ++ tests/framework_configs/converters.py | 24 + tests/framework_configs/output_adapters.py | 80 ++ .../test_strands_injection_spike.py | 313 ++++++++ uv.lock | 240 +++++- 16 files changed, 2441 insertions(+), 6 deletions(-) create mode 100644 examples/strands/01_basic_agent.py create mode 100644 examples/strands/02_custom_tools.py create mode 100644 examples/strands/setup_logging.py create mode 100644 src/band/adapters/strands.py create mode 100644 src/band/converters/strands.py create mode 100644 tests/adapters/test_strands_adapter.py create mode 100644 tests/converters/test_strands.py create mode 100644 tests/framework_conformance/test_strands_injection_spike.py diff --git a/examples/strands/01_basic_agent.py b/examples/strands/01_basic_agent.py new file mode 100644 index 000000000..1ac99e614 --- /dev/null +++ b/examples/strands/01_basic_agent.py @@ -0,0 +1,69 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[strands]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/band-ai/band-sdk-python.git" } +# /// +""" +Basic Strands Agents example. + +This is the simplest way to create a Band agent with AWS Strands Agents. +The adapter handles tool registration automatically. + +Strands has no provider-prefix model string (a bare string means a Bedrock +model id), so the OpenAI provider is constructed explicitly; it reads +OPENAI_API_KEY from the environment. + +Run with: + uv run examples/strands/01_basic_agent.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from dotenv import load_dotenv +from strands.models.openai import OpenAIModel + +from setup_logging import setup_logging +from band import Agent +from band.adapters import StrandsAdapter + +setup_logging() +logger = logging.getLogger(__name__) + + +async def main() -> None: + load_dotenv() + + ws_url = os.getenv("BAND_WS_URL") + rest_url = os.getenv("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") + + # Create adapter with framework-specific settings + adapter = StrandsAdapter( + model=OpenAIModel(model_id="gpt-5.4-mini"), + custom_section="You are a helpful assistant. Be concise and friendly.", + ) + + # Create and start agent + agent = Agent.from_config( + "strands_agent", + adapter=adapter, + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Starting Strands agent...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/strands/02_custom_tools.py b/examples/strands/02_custom_tools.py new file mode 100644 index 000000000..9e1f299a3 --- /dev/null +++ b/examples/strands/02_custom_tools.py @@ -0,0 +1,81 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["band-sdk[strands]"] +# +# [tool.uv.sources] +# band-sdk = { git = "https://github.com/band-ai/band-sdk-python.git" } +# /// +""" +Strands agent with custom tools and capabilities. + +Shows the portable ``CustomToolDef`` (InputModel, handler) form shared across +adapters, plus enabling memory/contact tools and execution/usage event +emission via AdapterFeatures. + +Run with: + uv run examples/strands/02_custom_tools.py +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from dotenv import load_dotenv +from pydantic import BaseModel +from strands.models.openai import OpenAIModel + +from setup_logging import setup_logging +from band import Agent +from band.adapters import StrandsAdapter +from band.core.types import AdapterFeatures, Capability, Emit + +setup_logging() +logger = logging.getLogger(__name__) + + +class WeatherInput(BaseModel): + """Get the weather for a city.""" + + city: str + + +async def get_weather(args: WeatherInput) -> str: + return f"{args.city}: sunny, 22°C" + + +async def main() -> None: + load_dotenv() + + ws_url = os.getenv("BAND_WS_URL") + rest_url = os.getenv("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") + + adapter = StrandsAdapter( + model=OpenAIModel(model_id="gpt-5.4-mini"), + custom_section="You can check the weather with the weather tool.", + additional_tools=[(WeatherInput, get_weather)], # CustomToolDef tuple + features=AdapterFeatures( + emit=frozenset({Emit.EXECUTION, Emit.USAGE}), + capabilities=frozenset({Capability.MEMORY, Capability.CONTACTS}), + ), + ) + + agent = Agent.from_config( + "strands_agent", + adapter=adapter, + ws_url=ws_url, + rest_url=rest_url, + ) + + logger.info("Starting Strands agent with custom tools...") + await agent.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/strands/setup_logging.py b/examples/strands/setup_logging.py new file mode 100644 index 000000000..e998d4549 --- /dev/null +++ b/examples/strands/setup_logging.py @@ -0,0 +1,12 @@ +"""Shared logging configuration for examples.""" + +from __future__ import annotations + +import logging + +from band import configure_logging + + +def setup_logging(level: int = logging.INFO) -> None: + """Configure logging to show only band logs, hiding noisy dependencies.""" + configure_logging(level) diff --git a/pyproject.toml b/pyproject.toml index 9f5733773..e54d32524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,9 @@ google_adk = [ agno = [ "agno>=2.6.0", ] +strands = [ + "strands-agents[openai]>=1.40,<2", +] # dev extra includes ALL framework deps for testing EXCEPT crewai, # which conflicts with both parlant and pydantic-ai (see tool.uv.conflicts). @@ -184,6 +187,8 @@ dev = [ "google-adk>=1.0.0,<2", # Include Agno for testing "agno>=2.6.0", + # Include Strands Agents for testing + "strands-agents[openai]>=1.40,<2", # Include letta-client for testing "letta-client>=0.1.0", # Include bridge deps for testing diff --git a/src/band/adapters/__init__.py b/src/band/adapters/__init__.py index 7e3823b5f..79fd161b7 100644 --- a/src/band/adapters/__init__.py +++ b/src/band/adapters/__init__.py @@ -16,6 +16,7 @@ uv add band-sdk[google_adk] uv add band-sdk[opencode] uv add band-sdk[slack] + uv add band-sdk[strands] """ from __future__ import annotations @@ -52,6 +53,7 @@ from band.adapters.slack import SlackAdapter as SlackAdapter from band.adapters.slack import SlackApp as SlackApp from band.adapters.slack import SlackSessionState as SlackSessionState + from band.adapters.strands import StrandsAdapter as StrandsAdapter __all__ = [ "LangGraphAdapter", @@ -78,6 +80,7 @@ "SlackAdapter", "SlackApp", "SlackSessionState", + "StrandsAdapter", ] @@ -171,6 +174,10 @@ def __getattr__(name: str) -> type: from band.adapters.letta import LettaAdapterConfig return LettaAdapterConfig + elif name == "StrandsAdapter": + from band.adapters.strands import StrandsAdapter + + return StrandsAdapter elif name in ("SlackAdapter", "SlackApp", "SlackSessionState"): from band.adapters.slack import SlackAdapter, SlackApp, SlackSessionState diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py new file mode 100644 index 000000000..1fcba80c1 --- /dev/null +++ b/src/band/adapters/strands.py @@ -0,0 +1,746 @@ +""" +Strands Agents adapter using SimpleAdapter pattern. + +Modeled on the pydantic-ai adapter: the framework owns the agent loop, the +model object is injectable at the public ``Agent(model=...)`` seam, and Band +platform tools are registered as native framework tools. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Callable, ClassVar + +import httpx + +try: + from strands import Agent, ToolContext, tool + from strands.hooks import HookProvider, HookRegistry + from strands.hooks.events import AfterToolCallEvent, BeforeToolCallEvent + from strands.models import Model + from strands.types.tools import ( + AgentTool, + ToolGenerator, + ToolResult, + ToolSpec, + ToolUse, + ) +except ImportError as e: + raise ImportError( + "Strands Agents dependencies not installed. " + "Install with: uv add band-sdk[strands]" + ) from e + +from band_rest.core.api_error import ApiError + +from band.core.protocols import AgentToolsProtocol +from band.core.simple_adapter import SimpleAdapter +from band.core.types import ( + AdapterFeatures, + Capability, + Emit, + PlatformMessage, + TurnUsage, +) +from band.converters.strands import StrandsHistoryConverter, StrandsMessages +from band.runtime.custom_tools import ( + CustomToolDef, + execute_custom_tool, + get_custom_tool_name, + is_marked_terminal, +) +from band.runtime.prompts import render_system_prompt +from band.runtime.tools import ( + band_tool_errored, + get_tool_description, + is_terminal_success, +) + +logger = logging.getLogger(__name__) + +# invocation_state key carrying the current turn's AgentToolsProtocol handle. +# Strands threads invocation_state from Agent.invoke_async(...) into every tool +# body (via ToolContext) and hook event, which is what lets the tool functions +# be built once while each turn binds its own tools handle. +_BAND_TOOLS_STATE_KEY = "band_tools" + + +def _tools_from_context(tool_context: ToolContext) -> AgentToolsProtocol: + """The current turn's platform tools handle, bound via invocation_state.""" + handle = tool_context.invocation_state.get(_BAND_TOOLS_STATE_KEY) + if handle is None: + raise RuntimeError( + "Band tools handle missing from invocation_state; platform tools " + "must be invoked through StrandsAdapter.on_message" + ) + return handle + + +def _result_text(result: ToolResult) -> str: + """Flatten a ToolResult's content blocks to text for error detection/reporting.""" + parts: list[str] = [] + for block in result.get("content", []): + if "text" in block: + parts.append(block["text"]) + elif "json" in block: + try: + parts.append(json.dumps(block["json"])) + except (TypeError, ValueError): + parts.append(str(block["json"])) + return "\n".join(parts) + + +class _CustomToolBridge(AgentTool): + """A portable ``CustomToolDef`` (InputModel, handler) as a native Strands tool. + + The tool spec is derived from the Pydantic input model (same name/schema the + other adapters expose), and dispatch validates + executes via + ``execute_custom_tool`` so the argument-validation contract matches the + tuple form everywhere else. + """ + + def __init__(self, tool_def: CustomToolDef): + super().__init__() + self._tool_def = tool_def + input_model, _ = tool_def + schema = input_model.model_json_schema() + schema.pop("title", None) + self._name = get_custom_tool_name(input_model) + self._spec: ToolSpec = { + "name": self._name, + "description": input_model.__doc__ or self._name, + "inputSchema": {"json": schema}, + } + + @property + def tool_name(self) -> str: + return self._name + + @property + def tool_spec(self) -> ToolSpec: + return self._spec + + @property + def tool_type(self) -> str: + return "function" + + async def stream( + self, tool_use: ToolUse, invocation_state: dict[str, Any], **kwargs: Any + ) -> ToolGenerator: + try: + result = await execute_custom_tool(self._tool_def, tool_use["input"] or {}) + yield { + "toolUseId": tool_use["toolUseId"], + "status": "success", + "content": [{"text": str(result)}], + } + except Exception as e: + yield { + "toolUseId": tool_use["toolUseId"], + "status": "error", + "content": [{"text": f"Error executing tool '{self._name}': {e}"}], + } + + +class _BandTurnHooks(HookProvider): + """Per-turn hook provider: L6 execution events + terminal-action tracking. + + A fresh instance is registered on each turn's Agent, so per-turn state + (whether a terminal Band action fired) needs no cross-turn bookkeeping. + Event emission is best-effort and never crashes the turn. + """ + + def __init__( + self, + tools: AgentToolsProtocol, + *, + emit_execution: bool, + custom_terminal_names: frozenset[str], + ): + self._tools = tools + self._emit_execution = emit_execution + self._custom_terminal_names = custom_terminal_names + self.terminal_fired = False + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(BeforeToolCallEvent, self._on_before_tool) + registry.add_callback(AfterToolCallEvent, self._on_after_tool) + + async def _on_before_tool(self, event: BeforeToolCallEvent) -> None: + if not self._emit_execution: + return + try: + await self._tools.send_event( + content=json.dumps( + { + "name": event.tool_use["name"], + "args": event.tool_use["input"], + "tool_call_id": event.tool_use["toolUseId"], + } + ), + message_type="tool_call", + ) + except Exception as e: + logger.warning("Failed to send tool_call event: %s", e) + + async def _on_after_tool(self, event: AfterToolCallEvent) -> None: + # Custom tools count as terminal only if they opted in (band_terminal); + # undeclared customs fail loud. A failed Band tool (its wrapper returns + # an "Error " string, or Strands recorded an error status) is not terminal. + name = event.tool_use["name"] + output = _result_text(event.result) + succeeded = event.result.get("status") == "success" and not band_tool_errored( + name, output + ) + if is_terminal_success( + name, + succeeded=succeeded, + custom_terminal=name in self._custom_terminal_names, + ): + self.terminal_fired = True + if not self._emit_execution: + return + try: + await self._tools.send_event( + content=json.dumps( + { + "name": name, + "output": output, + "tool_call_id": event.tool_use["toolUseId"], + } + ), + message_type="tool_result", + ) + except Exception as e: + logger.warning("Failed to send tool_result event: %s", e) + + +class StrandsAdapter(SimpleAdapter[StrandsMessages]): + """ + Strands Agents adapter using SimpleAdapter pattern. + + Uses a Strands Agent for LLM interactions, with platform tools registered + as native Strands ``@tool`` functions. + + Example: + from strands.models.openai import OpenAIModel + + adapter = StrandsAdapter( + model=OpenAIModel(model_id="gpt-5.4-mini"), + custom_section="You are a helpful assistant.", + ) + agent = Agent.create(adapter=adapter, agent_id="...", api_key="...") + await agent.run() + """ + + SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.EXECUTION, Emit.USAGE}) + SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset( + {Capability.MEMORY, Capability.CONTACTS} + ) + + def __init__( + self, + model: str | Model, + system_prompt: str | None = None, + custom_section: str | None = None, + history_converter: StrandsHistoryConverter | None = None, + additional_tools: list[Callable[..., Any] | CustomToolDef] | None = None, + features: AdapterFeatures | None = None, + ): + """ + Initialize the Strands adapter. + + Args: + model: A Strands ``Model`` instance (e.g. + ``strands.models.openai.OpenAIModel(model_id="gpt-5.4-mini")``). + A plain string is passed through to Strands, which treats it as + a **Bedrock** model id (Strands has no provider-prefix shorthand). + system_prompt: Optional custom system prompt (overrides default) + custom_section: Optional custom section added to default system prompt + history_converter: Optional custom history converter + additional_tools: Optional list of Strands-compatible tools (plain + callables or ``@strands.tool``-decorated functions) and/or + portable ``CustomToolDef`` (InputModel, handler) tuples. + features: Shared adapter feature settings (capabilities, emit, tool filters). + """ + super().__init__( + history_converter=history_converter or StrandsHistoryConverter(), + features=features, + ) + + self.model = model + self.system_prompt = system_prompt + self.custom_section = custom_section + self._system_prompt: str | None = None + + # Platform + custom tools, built once in on_started (tool bodies bind the + # per-turn tools handle via invocation_state, so they carry no turn state). + self._strands_tools: list[Any] = [] + + # Conversation history per room (Converse-shaped Messages). Band owns + # this state; Strands sessions/conversation managers are not used for + # cross-turn persistence. + self._message_history: dict[str, StrandsMessages] = {} + + # Custom tools: accept native Strands forms and the portable CustomToolDef + # (InputModel, handler) tuple the other adapters take — tuples are bridged + # to native Strands AgentTools; callables pass through unchanged. + self._custom_tools: list[Any] = [ + _CustomToolBridge(t) if isinstance(t, tuple) else t + for t in (additional_tools or []) + ] + # Custom tools that opt in as terminal actions (band_terminal=True on the + # handler/function). Only these let a turn with no Band-tool action count + # as productive; an undeclared custom tool does not (fail-loud — see + # is_terminal_success). + terminal_names: set[str] = set() + for raw, converted in zip(additional_tools or [], self._custom_tools): + handler = raw[1] if isinstance(raw, tuple) else raw + if is_marked_terminal(handler): + terminal_names.add( + converted.tool_name + if isinstance(converted, AgentTool) + else getattr(converted, "__name__", str(converted)) + ) + self._custom_terminal_names: frozenset[str] = frozenset(terminal_names) + + async def on_started(self, agent_name: str, agent_description: str) -> None: + """Render the system prompt and build the tool set after metadata is fetched.""" + await super().on_started(agent_name, agent_description) + self._system_prompt = self.system_prompt or render_system_prompt( + agent_name=self.agent_name, + agent_description=self.agent_description or "An AI assistant", + custom_section=self.custom_section or "", + features=self.features, + ) + self._strands_tools = self._build_platform_tools() + self._custom_tools + logger.info("Strands adapter started for agent: %s", agent_name) + + def _build_agent(self, messages: StrandsMessages, hooks: _BandTurnHooks) -> Agent: + """Construct the Strands Agent for one turn, over the room's history. + + A Strands Agent is stateful (it owns ``messages`` and per-agent metrics) + and raises on concurrent invocation, while the Band runtime runs one + asyncio task per room — so a single shared Agent would break under + multi-room concurrency. Instead the heavy pieces (prompt, tool set) are + built once in on_started and a lightweight Agent shell is constructed + per turn over the room's messages (mirrors the google_adk adapter's + fresh-runner-per-message pattern). Bonus: a fresh Agent has fresh + metrics, so accumulated usage is exactly this turn's usage. + """ + return Agent( + model=self.model, + messages=messages, + tools=self._strands_tools, + system_prompt=self._system_prompt, + hooks=[hooks], + callback_handler=None, + name=self.agent_name or None, + ) + + def _build_platform_tools(self) -> list[Any]: + """Build the Band platform tools as native Strands ``@tool`` functions. + + Descriptions come from the centralized tool definitions. All tools catch + exceptions and return "Error ..." strings so the LLM can see failures + (and so terminal detection can tell a failed Band tool from a success). + """ + + def band(name: str, fn: Callable[..., Any]) -> Any: + return tool(description=get_tool_description(name), context=True)(fn) + + async def band_send_message( + content: str, + mentions: list[str], + tool_context: ToolContext, + ) -> Any: + try: + return await _tools_from_context(tool_context).send_message( + content, mentions + ) + except Exception as e: + return f"Error sending message: {e}" + + async def band_send_event( + content: str, + message_type: str, + tool_context: ToolContext, + metadata: dict[str, Any] | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).send_event( + content, message_type, metadata + ) + except Exception as e: + return f"Error sending event: {e}" + + async def band_add_participant( + identifier: str, + tool_context: ToolContext, + role: str = "member", + ) -> Any: + try: + return await _tools_from_context(tool_context).add_participant( + identifier, role + ) + except Exception as e: + return f"Error adding participant '{identifier}': {e}" + + async def band_remove_participant( + identifier: str, + tool_context: ToolContext, + ) -> Any: + try: + return await _tools_from_context(tool_context).remove_participant( + identifier + ) + except Exception as e: + return f"Error removing participant '{identifier}': {e}" + + async def band_lookup_peers( + tool_context: ToolContext, + page: int = 1, + page_size: int = 50, + ) -> Any: + try: + return await _tools_from_context(tool_context).lookup_peers( + page, page_size + ) + except Exception as e: + return f"Error looking up peers: {e}" + + async def band_get_participants(tool_context: ToolContext) -> Any: + try: + return await _tools_from_context(tool_context).get_participants() + except Exception as e: + return f"Error getting participants: {e}" + + async def band_create_chatroom( + tool_context: ToolContext, + task_id: str | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).create_chatroom(task_id) + except Exception as e: + return f"Error creating chatroom (task_id={task_id}): {e}" + + strands_tools = [ + band("band_send_message", band_send_message), + band("band_send_event", band_send_event), + band("band_add_participant", band_add_participant), + band("band_remove_participant", band_remove_participant), + band("band_lookup_peers", band_lookup_peers), + band("band_get_participants", band_get_participants), + band("band_create_chatroom", band_create_chatroom), + ] + + # Contact management tools (opt-in via Capability.CONTACTS) + if Capability.CONTACTS in self.features.capabilities: + + async def band_list_contacts( + tool_context: ToolContext, + page: int = 1, + page_size: int = 50, + ) -> Any: + try: + return await _tools_from_context(tool_context).list_contacts( + page, page_size + ) + except Exception as e: + return f"Error listing contacts: {e}" + + async def band_add_contact( + handle: str, + tool_context: ToolContext, + message: str | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).add_contact( + handle, message + ) + except Exception as e: + return f"Error adding contact '{handle}': {e}" + + async def band_remove_contact( + tool_context: ToolContext, + handle: str | None = None, + contact_id: str | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).remove_contact( + handle, contact_id + ) + except Exception as e: + return f"Error removing contact: {e}" + + async def band_list_contact_requests( + tool_context: ToolContext, + page: int = 1, + page_size: int = 50, + sent_status: str = "pending", + ) -> Any: + try: + return await _tools_from_context( + tool_context + ).list_contact_requests(page, page_size, sent_status) + except Exception as e: + return f"Error listing contact requests: {e}" + + async def band_respond_contact_request( + action: str, + tool_context: ToolContext, + handle: str | None = None, + request_id: str | None = None, + ) -> Any: + tools = _tools_from_context(tool_context) + try: + return await tools.respond_contact_request( + action, handle, request_id + ) + except Exception as e: + error_msg = f"Error responding to contact request: {e}" + # Auto-send error event so it's visible in the room + try: + await tools.send_event(error_msg, "error") + except Exception: + pass # Don't fail if error reporting fails + return error_msg + + strands_tools.extend( + [ + band("band_list_contacts", band_list_contacts), + band("band_add_contact", band_add_contact), + band("band_remove_contact", band_remove_contact), + band("band_list_contact_requests", band_list_contact_requests), + band("band_respond_contact_request", band_respond_contact_request), + ] + ) + + # Memory management tools (enterprise only - opt-in) + if Capability.MEMORY in self.features.capabilities: + + async def band_list_memories( + tool_context: ToolContext, + subject_id: str | None = None, + scope: str | None = None, + system: str | None = None, + type: str | None = None, + segment: str | None = None, + content_query: str | None = None, + page_size: int = 50, + status: str | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).list_memories( + subject_id=subject_id, + scope=scope, + system=system, + type=type, + segment=segment, + content_query=content_query, + page_size=page_size, + status=status, + ) + except Exception as e: + return f"Error listing memories: {e}" + + async def band_store_memory( + content: str, + system: str, + type: str, + segment: str, + thought: str, + scope: str, + tool_context: ToolContext, + subject_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> Any: + try: + return await _tools_from_context(tool_context).store_memory( + content=content, + system=system, + type=type, + segment=segment, + thought=thought, + scope=scope, + subject_id=subject_id, + metadata=metadata, + ) + except Exception as e: + return f"Error storing memory: {e}" + + async def band_get_memory( + memory_id: str, + tool_context: ToolContext, + ) -> Any: + try: + return await _tools_from_context(tool_context).get_memory(memory_id) + except Exception as e: + return f"Error getting memory: {e}" + + async def band_supersede_memory( + memory_id: str, + tool_context: ToolContext, + ) -> Any: + try: + return await _tools_from_context(tool_context).supersede_memory( + memory_id + ) + except Exception as e: + return f"Error superseding memory: {e}" + + async def band_archive_memory( + memory_id: str, + tool_context: ToolContext, + ) -> Any: + try: + return await _tools_from_context(tool_context).archive_memory( + memory_id + ) + except Exception as e: + return f"Error archiving memory: {e}" + + strands_tools.extend( + [ + band("band_list_memories", band_list_memories), + band("band_store_memory", band_store_memory), + band("band_get_memory", band_get_memory), + band("band_supersede_memory", band_supersede_memory), + band("band_archive_memory", band_archive_memory), + ] + ) + + return strands_tools + + async def on_message( + self, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: StrandsMessages, # Already converted by SimpleAdapter + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> None: + """Handle incoming platform message.""" + if not self._strands_tools: + # Safety: build tools if not yet built (should be done in on_started) + self._strands_tools = self._build_platform_tools() + self._custom_tools + + # Initialize message history for this room on first message + # Note: history is already converted by SimpleAdapter via history_converter + if is_session_bootstrap: + if history: + self._message_history[room_id] = list(history) + logger.debug( + "Room %s: rehydrated %s message(s) from platform history", + room_id, + len(history), + ) + else: + self._message_history[room_id] = [] + elif room_id not in self._message_history: + # Safety: ensure history exists even if not first message + self._message_history[room_id] = [] + + # Inject participants message if changed + if participants_msg: + self._message_history[room_id].append( + {"role": "user", "content": [{"text": f"[System]: {participants_msg}"}]} + ) + logger.debug("Room %s: Injected participant update into history", room_id) + + # Inject contacts message if present + if contacts_msg: + self._message_history[room_id].append( + {"role": "user", "content": [{"text": f"[System]: {contacts_msg}"}]} + ) + logger.debug("Room %s: Injected contacts broadcast into history", room_id) + + # Build user message with sender prefix + user_message = msg.format_for_llm() + + logger.debug( + "Room %s: Running Strands agent (history: %s msgs, prompt: %s...)", + room_id, + len(self._message_history[room_id]), + user_message[:80], + ) + + turn_hooks = _BandTurnHooks( + tools, + emit_execution=Emit.EXECUTION in self.features.emit, + custom_terminal_names=self._custom_terminal_names, + ) + agent = self._build_agent(self._message_history[room_id], turn_hooks) + try: + await agent.invoke_async( + user_message, + invocation_state={_BAND_TOOLS_STATE_KEY: tools}, + ) + finally: + # Persist whatever the run recorded (also on a failed turn, so the + # room keeps the user prompt + any completed tool work), and emit + # this turn's usage: the per-turn Agent has per-turn metrics, so + # accumulated_usage is exactly this run's total across its model + # calls. emit_usage itself gates on Emit.USAGE and never raises. + self._message_history[room_id] = agent.messages + await self.emit_usage(tools, self._usage_from_agent(agent)) + + # A clean run with no terminal work means the model answered in plain text + # without calling band_send_message — a silently dropped reply. Surface it + # as an error (mirrors the pydantic-ai/crewai adapters). + if not turn_hooks.terminal_fired: + await self._report_error( + tools, + "Strands agent completed without sending a Band message. This " + "usually means the agent returned a final answer as plain text " + "instead of using the band_send_message tool.", + ) + + logger.debug( + "Room %s: Strands agent completed (history now has %s messages)", + room_id, + len(self._message_history[room_id]), + ) + + @staticmethod + def _usage_from_agent(agent: Agent) -> TurnUsage: + """Map the turn's accumulated token usage onto TurnUsage. + + Strands accumulates usage on the agent's EventLoopMetrics across all of + the run's model calls; reading it from the agent (not the AgentResult) + also covers turns that raised mid-run. totalTokens is derived + (input + output) and deliberately not mapped. + """ + try: + usage = dict(agent.event_loop_metrics.accumulated_usage) + except Exception: # pragma: no cover - defensive; usage is best-effort + return TurnUsage() + return TurnUsage.from_mapping( + usage, + input="inputTokens", + output="outputTokens", + cache_read="cacheReadInputTokens", + cache_write="cacheWriteInputTokens", + ) + + async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: + """Send an error event to the room (best effort). + + Narrowed to the REST call's real failure modes (ApiError = HTTP status, + httpx = transport) so a failed error-report never crashes the turn — + while a real bug still raises. + """ + try: + await tools.send_event(content=f"Error: {error}", message_type="error") + except (ApiError, httpx.HTTPError) as e: + logger.warning("Failed to send error event: %s", e) + + async def on_cleanup(self, room_id: str) -> None: + """Clean up message history when agent leaves a room.""" + if room_id in self._message_history: + del self._message_history[room_id] + logger.debug("Room %s: Cleaned up message history", room_id) diff --git a/src/band/converters/strands.py b/src/band/converters/strands.py new file mode 100644 index 000000000..ed3c2ef6c --- /dev/null +++ b/src/band/converters/strands.py @@ -0,0 +1,210 @@ +"""Strands Agents history converter.""" + +from __future__ import annotations + +import logging +from typing import Any + +try: + from strands.types.content import ContentBlock, Message +except ImportError as e: + raise ImportError( + "Strands Agents dependencies not installed. " + "Install with: uv add band-sdk[strands]" + ) from e + +from band.core.protocols import HistoryConverter +from band.core.types import MessageType + +from ._tool_parsing import parse_tool_call, parse_tool_result + +logger = logging.getLogger(__name__) + +# Type alias for Strands conversation history (Bedrock-Converse-shaped messages) +StrandsMessages = list[Message] + + +def _flush_pending_tool_calls( + messages: StrandsMessages, pending_tool_calls: list[ContentBlock] +) -> None: + """Flush pending toolUse blocks into a single assistant message.""" + if pending_tool_calls: + messages.append({"role": "assistant", "content": list(pending_tool_calls)}) + pending_tool_calls.clear() + + +def _flush_pending_tool_results( + messages: StrandsMessages, pending_tool_results: list[ContentBlock] +) -> None: + """Flush pending toolResult blocks into a single user message. + + Converse requires every toolUse in an assistant message to be answered by + toolResult blocks in the following user message, so results are batched + together to support parallel tool use. + """ + if pending_tool_results: + messages.append({"role": "user", "content": list(pending_tool_results)}) + pending_tool_results.clear() + + +class StrandsHistoryConverter(HistoryConverter[StrandsMessages]): + """ + Converts platform history to Strands (Bedrock Converse) message format. + + Output: + - user messages → {"role": "user", "content": [{"text": ...}]} + - other agents' messages → user role with [name] prefix + - tool_call → assistant message with a toolUse content block + - tool_result → user message with a toolResult content block + (status "error" when is_error=True) + - this agent's text messages → {"role": "assistant", "content": [{"text": ...}]} + (dropped when emitted mid tool call, where it would split the toolUse + from its toolResult) + + Tool events are stored in platform as JSON: + - tool_call: {"name": "...", "args": {...}, "tool_call_id": "..."} + - tool_result: {"name": "...", "output": "...", "tool_call_id": "...", "is_error": bool} + """ + + def __init__(self, agent_name: str = ""): + """ + Initialize converter. + + Args: + agent_name: Name of this agent. Messages from this agent are preserved + as assistant turns. Messages from other agents are included + as user turns with a [name] prefix. + """ + self._agent_name = agent_name + + def set_agent_name(self, name: str) -> None: + """ + Set agent name so the converter can recognize this agent's own messages. + + Args: + name: Name of this agent + """ + self._agent_name = name + + def convert(self, raw: list[dict[str, Any]]) -> StrandsMessages: + """Convert platform history to Strands Converse format.""" + messages: StrandsMessages = [] + # Collect tool calls to batch them into a single assistant message + pending_tool_calls: list[ContentBlock] = [] + # Collect tool results to batch them into a single user message + pending_tool_results: list[ContentBlock] = [] + + for hist in raw: + message_type = hist.get("message_type", "text") + content = hist.get("content", "") + + match message_type: + case MessageType.TOOL_CALL: + self._handle_tool_call( + content, messages, pending_tool_calls, pending_tool_results + ) + case MessageType.TOOL_RESULT: + self._handle_tool_result( + content, messages, pending_tool_calls, pending_tool_results + ) + case MessageType.TEXT: + self._handle_text( + hist, + content, + messages, + pending_tool_calls, + pending_tool_results, + ) + case MessageType.THOUGHT | MessageType.ERROR | MessageType.TASK: + # Known platform-internal types intentionally excluded from + # LLM history. + pass + case _: + logger.warning("Unknown message_type in history: %s", message_type) + + # Flush any remaining pending tool calls and results + _flush_pending_tool_calls(messages, pending_tool_calls) + _flush_pending_tool_results(messages, pending_tool_results) + + return messages + + def _handle_tool_call( + self, + content: str, + messages: StrandsMessages, + pending_tool_calls: list[ContentBlock], + pending_tool_results: list[ContentBlock], + ) -> None: + """Collect a tool call for batching, flushing any pending results first.""" + _flush_pending_tool_results(messages, pending_tool_results) + + parsed = parse_tool_call(content) + if parsed: + pending_tool_calls.append( + { + "toolUse": { + "toolUseId": parsed.tool_call_id, + "name": parsed.name, + "input": parsed.args, + } + } + ) + + def _handle_tool_result( + self, + content: str, + messages: StrandsMessages, + pending_tool_calls: list[ContentBlock], + pending_tool_results: list[ContentBlock], + ) -> None: + """Collect a tool result for batching, flushing the calls it answers first.""" + _flush_pending_tool_calls(messages, pending_tool_calls) + + parsed = parse_tool_result(content) + if not parsed: + return + + pending_tool_results.append( + { + "toolResult": { + "toolUseId": parsed.tool_call_id, + "status": "error" if parsed.is_error else "success", + "content": [{"text": parsed.output}], + } + } + ) + + def _handle_text( + self, + hist: dict[str, Any], + content: str, + messages: StrandsMessages, + pending_tool_calls: list[ContentBlock], + pending_tool_results: list[ContentBlock], + ) -> None: + """Append a text turn: own text as assistant, others as user.""" + role = hist.get("role", "user") + sender_name = hist.get("sender_name", "") + is_own = ( + role == "assistant" and self._agent_name and sender_name == self._agent_name + ) + + # Own platform text while a tool call is unresolved is usually the side + # effect of band_send_message. Replaying it here would split the toolUse + # from its toolResult, which Converse rejects. + if is_own and pending_tool_calls: + return + + # Flush pending tool calls and results first + _flush_pending_tool_calls(messages, pending_tool_calls) + _flush_pending_tool_results(messages, pending_tool_results) + + if is_own: + # Preserve own text so restart rehydration knows the agent already replied. + messages.append({"role": "assistant", "content": [{"text": content}]}) + else: + # User messages AND other agents' messages + formatted_content = ( + f"[{sender_name}]: {content}" if sender_name else content + ) + messages.append({"role": "user", "content": [{"text": formatted_content}]}) diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py new file mode 100644 index 000000000..c1375f0f3 --- /dev/null +++ b/tests/adapters/test_strands_adapter.py @@ -0,0 +1,412 @@ +"""Unit tests for the Strands adapter (mocked model, no live inference).""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, cast + +import pytest +from pydantic import BaseModel + +pytest.importorskip("strands", reason="strands extra not installed") + +from strands import tool as strands_tool # noqa: E402 +from strands.models import Model # noqa: E402 +from strands.types.content import Messages # noqa: E402 +from strands.types.streaming import StreamEvent # noqa: E402 +from strands.types.tools import ToolSpec # noqa: E402 + +from band.adapters.strands import StrandsAdapter, _CustomToolBridge # noqa: E402 +from band.converters.strands import StrandsHistoryConverter # noqa: E402 +from band.core.protocols import AgentToolsProtocol # noqa: E402 +from band.core.types import ( # noqa: E402 + AdapterFeatures, + Capability, + Emit, + PlatformMessage, + TurnUsage, +) +from band.testing.fake_tools import FakeAgentTools # noqa: E402 + + +class _ScriptedModel(Model): + """Replays scripted ("tool", name, args) / ("text", body) decisions.""" + + def __init__(self, turns: list[Any]): + self._turns = list(turns) + self._config: dict[str, Any] = {} + + def update_config(self, **model_config: Any) -> None: + self._config.update(model_config) + + def get_config(self) -> Any: + return self._config + + async def structured_output( + self, + output_model: Any, + prompt: Messages, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[dict[str, Any], None]: + raise NotImplementedError + yield {} # pragma: no cover - makes this an async generator + + async def stream( + self, + messages: Messages, + tool_specs: list[ToolSpec] | None = None, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[StreamEvent, None]: + decision = self._turns.pop(0) if self._turns else ("text", "done") + yield {"messageStart": {"role": "assistant"}} + if decision[0] == "tool": + _, name, args = decision + yield { + "contentBlockStart": { + "start": {"toolUse": {"toolUseId": f"call-{name}", "name": name}} + } + } + yield { + "contentBlockDelta": {"delta": {"toolUse": {"input": json.dumps(args)}}} + } + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "tool_use"}} + else: + yield {"contentBlockStart": {"start": {}}} + yield {"contentBlockDelta": {"delta": {"text": decision[1]}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + yield { + "metadata": { + "usage": {"inputTokens": 7, "outputTokens": 3, "totalTokens": 10}, + "metrics": {"latencyMs": 1}, + } + } + + +def _make_msg(room_id: str, content: str = "Hello") -> PlatformMessage: + return PlatformMessage( + id="msg-1", + room_id=room_id, + content=content, + sender_id="user-1", + sender_type="User", + sender_name="Tester", + message_type="text", + metadata=None, + created_at=datetime.now(timezone.utc), + ) + + +async def _run_message( + adapter: StrandsAdapter, + tools: FakeAgentTools, + room_id: str, + *, + history: list | None = None, + participants_msg: str | None = None, + contacts_msg: str | None = None, + is_session_bootstrap: bool = True, +) -> None: + await adapter.on_message( + msg=_make_msg(room_id), + tools=cast("AgentToolsProtocol", tools), + history=history or [], + participants_msg=participants_msg, + contacts_msg=contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) + + +_SEND_TURN = ( + "tool", + "band_send_message", + {"content": "hi", "mentions": ["@tester"]}, +) + + +class TestInitialization: + def test_defaults(self): + adapter = StrandsAdapter(model="some-bedrock-model-id") + + assert adapter.model == "some-bedrock-model-id" + assert adapter.system_prompt is None + assert adapter.custom_section is None + assert adapter._custom_tools == [] + assert adapter._custom_terminal_names == frozenset() + assert isinstance(adapter.history_converter, StrandsHistoryConverter) + assert adapter.features == AdapterFeatures() + + def test_feature_declarations(self): + assert StrandsAdapter.SUPPORTED_EMIT == frozenset({Emit.EXECUTION, Emit.USAGE}) + assert StrandsAdapter.SUPPORTED_CAPABILITIES == frozenset( + {Capability.MEMORY, Capability.CONTACTS} + ) + + +class TestCustomToolWiring: + def test_custom_tool_def_converted_to_bridge(self): + class WeatherInput(BaseModel): + """Get the weather for a city.""" + + city: str + + async def get_weather(args: WeatherInput) -> str: + return f"{args.city}: sunny" + + adapter = StrandsAdapter( + model="m", additional_tools=[(WeatherInput, get_weather)] + ) + + assert len(adapter._custom_tools) == 1 + bridge = adapter._custom_tools[0] + assert isinstance(bridge, _CustomToolBridge) + assert bridge.tool_name == "weather" + assert bridge.tool_spec["description"] == "Get the weather for a city." + assert ( + bridge.tool_spec["inputSchema"]["json"]["properties"]["city"]["type"] + == "string" + ) + # Not marked band_terminal -> not a terminal action. + assert adapter._custom_terminal_names == frozenset() + + def test_terminal_marker_captured_from_tuple_handler(self): + class DoneInput(BaseModel): + """Finish the task.""" + + note: str + + async def finish(args: DoneInput) -> str: + return "done" + + finish.band_terminal = True # type: ignore[attr-defined] + + adapter = StrandsAdapter(model="m", additional_tools=[(DoneInput, finish)]) + + assert adapter._custom_terminal_names == frozenset({"done"}) + + def test_terminal_marker_captured_from_native_tool(self): + @strands_tool + def native_finish(note: str) -> str: + """Finish the task natively.""" + return "done" + + native_finish.band_terminal = True # type: ignore[attr-defined] + + adapter = StrandsAdapter(model="m", additional_tools=[native_finish]) + + assert adapter._custom_terminal_names == frozenset({"native_finish"}) + + +class TestToolRegistration: + @pytest.mark.asyncio + async def test_base_tools_only_by_default(self): + adapter = StrandsAdapter(model="m") + await adapter.on_started("Bot", "A bot") + + names = {t.tool_name for t in adapter._strands_tools} + assert names == { + "band_send_message", + "band_send_event", + "band_add_participant", + "band_remove_participant", + "band_lookup_peers", + "band_get_participants", + "band_create_chatroom", + } + + @pytest.mark.asyncio + async def test_capability_gated_tools_registered(self): + adapter = StrandsAdapter( + model="m", + features=AdapterFeatures( + capabilities={Capability.MEMORY, Capability.CONTACTS} + ), + ) + await adapter.on_started("Bot", "A bot") + + names = {t.tool_name for t in adapter._strands_tools} + assert {"band_list_contacts", "band_respond_contact_request"} <= names + assert {"band_store_memory", "band_archive_memory"} <= names + + @pytest.mark.asyncio + async def test_platform_tool_descriptions_from_registry(self): + from band.runtime.tools import get_tool_description + + adapter = StrandsAdapter(model="m") + await adapter.on_started("Bot", "A bot") + + by_name = {t.tool_name: t for t in adapter._strands_tools} + assert by_name["band_send_message"].tool_spec[ + "description" + ] == get_tool_description("band_send_message") + + +class TestOnMessage: + @pytest.mark.asyncio + async def test_send_message_turn_dispatches_and_persists_history(self): + room_id = "room-1" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([_SEND_TURN, ("text", "done")])) + await adapter.on_started("Bot", "A bot") + + await _run_message(adapter, tools, room_id) + + tools.assert_message_sent(content="hi", mentions=["@tester"], count=1) + # user prompt + toolUse + toolResult + final text + assert len(adapter._message_history[room_id]) == 4 + + @pytest.mark.asyncio + async def test_bootstrap_rehydrates_history(self): + room_id = "room-rehydrate" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([_SEND_TURN, ("text", "done")])) + await adapter.on_started("Bot", "A bot") + + prior = [ + {"role": "user", "content": [{"text": "[Tester]: earlier question"}]}, + {"role": "assistant", "content": [{"text": "earlier answer"}]}, + ] + await _run_message(adapter, tools, room_id, history=list(prior)) + + persisted = adapter._message_history[room_id] + assert persisted[:2] == prior + assert len(persisted) > 2 # this turn appended on top + + @pytest.mark.asyncio + async def test_participants_and_contacts_injected_as_system_turns(self): + room_id = "room-inject" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([_SEND_TURN, ("text", "done")])) + await adapter.on_started("Bot", "A bot") + + await _run_message( + adapter, + tools, + room_id, + participants_msg="Alice joined", + contacts_msg="Bob is now a contact", + ) + + texts = [ + block["text"] + for message in adapter._message_history[room_id] + for block in message["content"] + if "text" in block + ] + assert "[System]: Alice joined" in texts + assert "[System]: Bob is now a contact" in texts + + @pytest.mark.asyncio + async def test_no_terminal_action_reports_error(self): + room_id = "room-noop" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([("text", "plain answer")])) + await adapter.on_started("Bot", "A bot") + + await _run_message(adapter, tools, room_id) + + errors = [e for e in tools.events_sent if e["message_type"] == "error"] + assert len(errors) == 1 + assert "band_send_message" in errors[0]["content"] + + @pytest.mark.asyncio + async def test_failed_band_tool_is_not_terminal(self): + """A platform tool whose wrapper returns "Error ..." does not end the turn productively.""" + + class FailingTools(FakeAgentTools): + async def send_message(self, content, mentions=None): + raise RuntimeError("backend down") + + room_id = "room-fail" + tools = FailingTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([_SEND_TURN, ("text", "done")])) + await adapter.on_started("Bot", "A bot") + + await _run_message(adapter, tools, room_id) + + assert tools.messages_sent == [] + errors = [e for e in tools.events_sent if e["message_type"] == "error"] + assert len(errors) == 1 + # The failed call is visible to the model as an "Error ..." tool result. + result_texts = [ + item["text"] + for message in adapter._message_history[room_id] + for block in message["content"] + if "toolResult" in block + for item in block["toolResult"]["content"] + if "text" in item + ] + assert any(t.startswith("Error sending message:") for t in result_texts) + + @pytest.mark.asyncio + async def test_usage_emitted_once_per_turn(self): + room_id = "room-usage" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter( + model=_ScriptedModel([_SEND_TURN, ("text", "done")]), + features=AdapterFeatures(emit={Emit.USAGE}), + ) + await adapter.on_started("Bot", "A bot") + + await _run_message(adapter, tools, room_id) + + from band.core.types import USAGE_METADATA_KEY, is_usage_event + + usage_events = [e for e in tools.events_sent if is_usage_event(e["metadata"])] + assert len(usage_events) == 1 + # Two scripted model calls of 7/3 each -> the turn total, not the last call. + assert usage_events[0]["metadata"][USAGE_METADATA_KEY] == { + "input_tokens": 14, + "output_tokens": 6, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + } + + +class TestUsageMapping: + def test_usage_from_agent_maps_all_fields(self): + class _Metrics: + accumulated_usage = { + "inputTokens": 10, + "outputTokens": 5, + "totalTokens": 15, + "cacheReadInputTokens": 3, + "cacheWriteInputTokens": 2, + } + + class _Agent: + event_loop_metrics = _Metrics() + + usage = StrandsAdapter._usage_from_agent(cast("Any", _Agent())) + + assert usage == TurnUsage( + input_tokens=10, + output_tokens=5, + cache_read_tokens=3, + cache_write_tokens=2, + ) + + +class TestCleanup: + @pytest.mark.asyncio + async def test_cleanup_unknown_room_is_noop(self): + adapter = StrandsAdapter(model="m") + await adapter.on_cleanup("never-seen-room") # must not raise + + @pytest.mark.asyncio + async def test_cleanup_removes_room_history(self): + room_id = "room-clean" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter(model=_ScriptedModel([_SEND_TURN, ("text", "done")])) + await adapter.on_started("Bot", "A bot") + await _run_message(adapter, tools, room_id) + assert room_id in adapter._message_history + + await adapter.on_cleanup(room_id) + + assert room_id not in adapter._message_history diff --git a/tests/converters/test_strands.py b/tests/converters/test_strands.py new file mode 100644 index 000000000..7247ffaaf --- /dev/null +++ b/tests/converters/test_strands.py @@ -0,0 +1,177 @@ +"""Unit tests for the Strands history converter (framework-specific behavior). + +The shared behavioral contract is covered by +tests/framework_conformance/test_converter_conformance.py; these tests pin the +Converse-specific output shapes: toolUse/toolResult blocks, batching, and +malformed tool-event handling. +""" + +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("strands", reason="strands extra not installed") + +from band.converters.strands import StrandsHistoryConverter # noqa: E402 + + +def _tool_call(name: str, args: dict, call_id: str) -> dict: + return { + "role": "assistant", + "content": json.dumps({"name": name, "args": args, "tool_call_id": call_id}), + "message_type": "tool_call", + } + + +def _tool_result(name: str, output: str, call_id: str, is_error: bool = False) -> dict: + return { + "role": "assistant", + "content": json.dumps( + { + "name": name, + "output": output, + "tool_call_id": call_id, + "is_error": is_error, + } + ), + "message_type": "tool_result", + } + + +class TestToolEventFormat: + def test_tool_call_becomes_assistant_tool_use_block(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [_tool_call("band_send_message", {"content": "hi"}, "call-1")] + ) + + assert result == [ + { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "call-1", + "name": "band_send_message", + "input": {"content": "hi"}, + } + } + ], + } + ] + + def test_tool_result_becomes_user_tool_result_block(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [ + _tool_call("search", {"q": "x"}, "call-1"), + _tool_result("search", "found it", "call-1"), + ] + ) + + assert result[1] == { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "call-1", + "status": "success", + "content": [{"text": "found it"}], + } + } + ], + } + + def test_error_tool_result_gets_error_status(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [ + _tool_call("search", {}, "call-1"), + _tool_result("search", "boom", "call-1", is_error=True), + ] + ) + + assert result[1]["content"][0]["toolResult"]["status"] == "error" + + +class TestBatching: + def test_parallel_tool_calls_batch_into_one_assistant_message(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [ + _tool_call("a", {}, "call-a"), + _tool_call("b", {}, "call-b"), + _tool_result("a", "ra", "call-a"), + _tool_result("b", "rb", "call-b"), + ] + ) + + assert len(result) == 2 + assert result[0]["role"] == "assistant" + assert [b["toolUse"]["toolUseId"] for b in result[0]["content"]] == [ + "call-a", + "call-b", + ] + assert result[1]["role"] == "user" + assert [b["toolResult"]["toolUseId"] for b in result[1]["content"]] == [ + "call-a", + "call-b", + ] + + def test_own_text_mid_tool_call_is_dropped(self): + """Own text between a toolUse and its toolResult would split the pair.""" + converter = StrandsHistoryConverter(agent_name="Bot") + + result = converter.convert( + [ + _tool_call("band_send_message", {"content": "hi"}, "call-1"), + { + "role": "assistant", + "content": "hi", + "sender_name": "Bot", + "message_type": "text", + }, + _tool_result("band_send_message", "sent", "call-1"), + ] + ) + + assert len(result) == 2 + assert "toolUse" in result[0]["content"][0] + assert "toolResult" in result[1]["content"][0] + + +class TestMalformedInput: + def test_malformed_tool_events_are_skipped(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [ + { + "role": "assistant", + "content": "not json at all", + "message_type": "tool_call", + }, + { + "role": "assistant", + "content": json.dumps({"name": "x"}), # missing tool_call_id + "message_type": "tool_result", + }, + ] + ) + + assert result == [] + + def test_unknown_message_type_is_skipped(self): + converter = StrandsHistoryConverter() + + result = converter.convert( + [{"role": "user", "content": "hi", "message_type": "mystery"}] + ) + + assert result == [] diff --git a/tests/e2e/baseline/toolkit/adapters.py b/tests/e2e/baseline/toolkit/adapters.py index 87aa22edd..f2441a44a 100644 --- a/tests/e2e/baseline/toolkit/adapters.py +++ b/tests/e2e/baseline/toolkit/adapters.py @@ -93,6 +93,7 @@ class Adapter(StrEnum): CODEX = "codex" OPENCODE = "opencode" LETTA = "letta" + STRANDS = "strands" # A builder turns settings (+ optional steering prompt / features) into a diff --git a/tests/e2e/baseline/toolkit/builders.py b/tests/e2e/baseline/toolkit/builders.py index 01a62e4dd..c38026ae4 100644 --- a/tests/e2e/baseline/toolkit/builders.py +++ b/tests/e2e/baseline/toolkit/builders.py @@ -127,6 +127,32 @@ def _build_pydantic_ai( ) +@adapter(Adapter.STRANDS, requires=[Dep.OPENAI], supports=_LLM_TOOL_LOOP) +def _build_strands( + s: BaselineSettings, + *, + prompt: str | None, + features: AdapterFeatures | None, + tools: list[ToolSpec] | None = None, +) -> SimpleAdapter[Any]: + from strands.models.openai import OpenAIModel + + from band.adapters.strands import StrandsAdapter + + # Strands has no provider-prefix string shorthand (a bare string means a + # Bedrock model id), so the OpenAI provider is constructed explicitly. + api_key = s.llm_credentials.openai_api_key + return StrandsAdapter( + model=OpenAIModel( + client_args={"api_key": api_key} if api_key else None, + model_id=s.llm_models.openai_model, + ), + custom_section=prompt, + additional_tools=_custom_tool_defs(tools), + features=features, + ) + + @adapter(Adapter.GEMINI, requires=[Dep.GOOGLE], supports=_LLM_TOOL_LOOP) def _build_gemini( s: BaselineSettings, diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 29c607c04..a2b25cfa1 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -233,6 +233,14 @@ def _pydantic_ai_factory(**kw: Any) -> Any: return PydanticAIAdapter(**kw) +def _strands_factory(**kw: Any) -> Any: + from band.adapters.strands import StrandsAdapter + + if "model" not in kw: + kw["model"] = _STRANDS_INJECTED_MODEL + return StrandsAdapter(**kw) + + def _parlant_factory(**kw: Any) -> Any: from band.adapters.parlant import ParlantAdapter @@ -296,6 +304,11 @@ def _google_adk_factory(**kw: Any) -> Any: # the factory injection, NOT a real adapter default. _PYDANTIC_AI_INJECTED_MODEL = "openai:gpt-5.4" +# Strands likewise requires ``model``. A plain string is fine at construction +# time: the adapter passes it through to Strands (which treats strings as +# Bedrock model ids), and no client is created until the first message turn. +_STRANDS_INJECTED_MODEL = "strands-conformance-model" + def _build_anthropic_config() -> AdapterConfig: from band.adapters.anthropic import AnthropicAdapter @@ -557,6 +570,36 @@ def _build_pydantic_ai_config() -> AdapterConfig: ) +def _build_strands_config() -> AdapterConfig: + from band.adapters.strands import StrandsAdapter + + return AdapterConfig( + framework_id="strands", + display_name="Strands", + adapter_factory=_strands_factory, + expected_initial_values={ + # Injected by _strands_factory, not a real __init__ default. + # Verifies that the factory injection is stored correctly. + "model": _STRANDS_INJECTED_MODEL, + "system_prompt": _default_from_init(StrandsAdapter, "system_prompt"), + "custom_section": _default_from_init(StrandsAdapter, "custom_section"), + }, + custom_kwargs={ + "model": "custom-bedrock-model-id", + "system_prompt": "You are a helpful bot.", + "custom_section": "Be concise.", + }, + custom_expected={ + "model": "custom-bedrock-model-id", + "system_prompt": "You are a helpful bot.", + "custom_section": "Be concise.", + }, + # on_started only renders the prompt and builds tool wrappers — no + # client/agent construction happens until the first message turn. + skip_on_started_conformance=False, + ) + + def _build_parlant_config() -> AdapterConfig: from band.adapters.parlant import ParlantAdapter @@ -772,6 +815,7 @@ def _build_google_adk_config() -> AdapterConfig: _build_crewai_flow_config, _build_claude_sdk_config, _build_pydantic_ai_config, + _build_strands_config, _build_parlant_config, _build_codex_config, _build_letta_config, diff --git a/tests/framework_configs/converters.py b/tests/framework_configs/converters.py index 34fdac32e..2bb023862 100644 --- a/tests/framework_configs/converters.py +++ b/tests/framework_configs/converters.py @@ -130,6 +130,12 @@ def _google_adk_factory(**kw: Any) -> Any: return GoogleADKHistoryConverter(**kw) +def _strands_factory(**kw: Any) -> Any: + from band.converters.strands import StrandsHistoryConverter + + return StrandsHistoryConverter(**kw) + + # --------------------------------------------------------------------------- # Registry (built lazily to avoid top-level converter imports) # --------------------------------------------------------------------------- @@ -319,6 +325,23 @@ def _build_google_adk_config() -> ConverterConfig: ) +def _build_strands_config() -> ConverterConfig: + from tests.framework_configs.output_adapters import StrandsOutputAdapter + + return ConverterConfig( + framework_id="strands", + display_name="Strands", + converter_factory=_strands_factory, + empty_result=[], + empty_sender_behavior=SenderBehavior.CONTENT_AS_IS, + missing_sender_behavior=SenderBehavior.CONTENT_AS_IS, + # Keeps own text as an assistant turn so restart rehydration shows the + # agent's prior replies (same contract as pydantic-ai). + includes_own_text_without_tool_events=True, + output_adapter=StrandsOutputAdapter(), + ) + + _CONVERTER_CONFIG_BUILDERS: list[Callable[[], ConverterConfig]] = [ _build_anthropic_config, _build_langchain_config, @@ -329,6 +352,7 @@ def _build_google_adk_config() -> ConverterConfig: _build_agno_config, _build_gemini_config, _build_google_adk_config, + _build_strands_config, ] diff --git a/tests/framework_configs/output_adapters.py b/tests/framework_configs/output_adapters.py index 9e59792be..5c011caae 100644 --- a/tests/framework_configs/output_adapters.py +++ b/tests/framework_configs/output_adapters.py @@ -686,3 +686,83 @@ def assert_sender_metadata( assert msg.get("sender_type") == sender_type, ( f"Expected sender_type={sender_type!r}, got {msg.get('sender_type')!r}" ) + + +class StrandsOutputAdapter: + """Adapter for Strands converter output (list of Converse Message dicts). + + Each message is ``{"role": "user"|"assistant", "content": [blocks]}`` where a + block is ``{"text": ...}``, ``{"toolUse": ...}``, or ``{"toolResult": ...}``. + Batched tool blocks share one message, so indexed accessors operate on a + flattened (role, block) view (same approach as ``GeminiOutputAdapter``). + """ + + @staticmethod + def _flatten(result: list) -> list[tuple[str, dict[str, Any]]]: + """Return a flat list of (role, content_block) tuples.""" + flat: list[tuple[str, dict[str, Any]]] = [] + for message in result: + for block in message.get("content", []): + flat.append((message["role"], block)) + return flat + + 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(self._flatten(result)) + + def get_content(self, result: list, index: int) -> str: + _role, block = self._flatten(result)[index] + if "text" in block: + return block["text"] + if "toolUse" in block: + return block["toolUse"].get("name", "") + if "toolResult" in block: + parts = [ + item["text"] + for item in block["toolResult"].get("content", []) + if "text" in item + ] + return "\n".join(parts) + raise ValueError(f"No text/toolUse/toolResult content at index {index}") + + def get_role(self, result: list, index: int) -> str: + role, _block = self._flatten(result)[index] + return role + + def is_empty(self, result: list) -> bool: + return len(result) == 0 + + def content_contains(self, result: list, substring: str) -> bool: + for role, block in self._flatten(result): + if "text" in block and substring in block["text"]: + return True + if "toolUse" in block: + tool_use = block["toolUse"] + if substring in tool_use.get("name", ""): + return True + if substring in str(tool_use.get("input", {})): + return True + if "toolResult" in block: + for item in block["toolResult"].get("content", []): + if "text" in item and substring in item["text"]: + return True + return False + + def assert_element_type(self, result: list, index: int, expected_role: str) -> None: + role = self.get_role(result, index) + assert role == expected_role, f"Expected role={expected_role!r}, got {role!r}" + + def assert_sender_metadata( + self, + result: list, + index: int, + sender_name: str, + sender_type: str | None = None, + ) -> None: + raise NotImplementedError( + "StrandsOutputAdapter.assert_sender_metadata() is not supported. " + "Converse messages do not include sender metadata. " + "Ensure has_sender_metadata=False in the ConverterConfig." + ) diff --git a/tests/framework_conformance/test_strands_injection_spike.py b/tests/framework_conformance/test_strands_injection_spike.py new file mode 100644 index 000000000..83998f8be --- /dev/null +++ b/tests/framework_conformance/test_strands_injection_spike.py @@ -0,0 +1,313 @@ +"""Tier-1 conformance spike for the Strands adapter (INJECTABLE_MODEL_OBJECT family). + +WHAT THIS PROVES +---------------- +Given a scripted model decision, the StrandsAdapter's real tool wrappers (built +by ``_build_platform_tools`` and run by Strands' own agent loop) dispatch the +tool to the platform via ``AgentToolsProtocol`` — no live inference, no secrets, +no provider API key. + +WHY IT IS HONEST (not circular) +------------------------------- +The faked decision is installed at the **public** ``Agent(model=...)`` seam: +``strands.models.Model`` is the framework's documented provider ABC, and the +adapter passes its ``model`` constructor argument straight through in +``_build_agent``. Strands ships no dedicated test model, so the spike implements +the minimal ``Model`` subclass; the scripted surface is still the stable public +provider contract (``model_seam_kind=PUBLIC_TEST_MODEL``), though the +``StreamEvent`` dict shapes it emits move with the framework's fast release +cadence (``drift_risk=HIGH``, pinned ``strands-agents>=1.40,<2``). + +OBSERVATION PATH (the load-bearing detail) +------------------------------------------ +Strands platform-tool wrappers call **typed** ``AgentToolsProtocol`` methods +directly — ``band_send_message`` calls ``tools.send_message(...)`` — NOT +``execute_tool_call``. So dispatch is observed on ``FakeAgentTools.messages_sent`` +(``observation_paths={TYPED_METHODS}``); a canary that only watched +``tool_calls`` would wrongly fail Strands. + +STEP-0 FINDINGS (verified against installed strands-agents 1.47.0) +------------------------------------------------------------------ +1. Per-invocation context: ``agent.invoke_async(prompt, invocation_state={...})`` + reaches tool bodies via ``@tool(context=True)`` (``tool_context.invocation_state``) + and every hook event (``event.invocation_state``). No fallback needed. +2. Scripted model events (consumed by ``strands.event_loop.streaming``): one tool + call is ``messageStart{role:assistant}`` -> + ``contentBlockStart{start:{toolUse:{toolUseId,name}}}`` -> + ``contentBlockDelta{delta:{toolUse:{input:}}}`` -> + ``contentBlockStop`` -> ``messageStop{stopReason:"tool_use"}``; a terminal + text turn uses ``delta:{text}`` + ``stopReason:"end_turn"``; per-call usage + rides an optional trailing ``metadata{usage}`` event. The ``Model`` ABC also + requires ``structured_output`` (abstract), implemented as a stub here. +3. OpenAI provider: ``strands.models.openai.OpenAIModel(client_args={"api_key":...}, + model_id=...)``. There is NO provider-prefix string shorthand — a bare string + to ``Agent(model=...)`` is treated as a *Bedrock* model id. +4. Hook events: ``BeforeToolCallEvent.tool_use{name,toolUseId,input}``, + ``AfterToolCallEvent.result: ToolResult{status,content,toolUseId}``; async + callbacks are supported. Usage lives on ``AgentResult.metrics.accumulated_usage`` + (``inputTokens``/``outputTokens``/``cacheReadInputTokens``/``cacheWriteInputTokens``) + and accumulates across turns on a shared Agent — the adapter constructs a + per-turn Agent, so the accumulated value is exactly the turn's usage. +5. History handle: ``agent.messages`` is a public read/write list of Converse + ``Message`` dicts; pre-seeding via ``Agent(messages=...)`` and reading back + after a run both work, so Band owns per-room history. + +InjectionBinding for the Tier-1 injection registry (lands with the taxonomy +branch; recorded here until ``injection_registry.py`` exists on this branch): + + InjectionBinding( + adapter="strands", + family=Family.INJECTABLE_MODEL_OBJECT, + tier1_status=Tier1Status.HONEST_TODAY, + drift_risk=DriftRisk.HIGH, + observation_paths=frozenset({ObservationPath.TYPED_METHODS}), + seam="band.adapters.strands:StrandsAdapter._build_agent", + model_seam_kind=ModelSeamKind.PUBLIC_TEST_MODEL, + spike_test="tests/framework_conformance/test_strands_injection_spike.py", + version_pin="strands-agents>=1.40,<2", + required_modules=("strands",), + required_extra="dev", + ) +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, cast + +import pytest +from pydantic import BaseModel + +pytest.importorskip("strands", reason="strands extra not installed") + +from strands.models import Model # noqa: E402 +from strands.types.content import Messages # noqa: E402 +from strands.types.streaming import StreamEvent # noqa: E402 +from strands.types.tools import ToolSpec # noqa: E402 + +from band.adapters.strands import StrandsAdapter # noqa: E402 +from band.core.protocols import AgentToolsProtocol # noqa: E402 +from band.core.types import AdapterFeatures, Emit, PlatformMessage # noqa: E402 +from band.testing.fake_tools import FakeAgentTools # noqa: E402 + +_SEND_CONTENT = "Injected reply: PINEAPPLE" +_SEND_MENTIONS = ["@tester"] + + +def _make_msg(room_id: str) -> PlatformMessage: + return PlatformMessage( + id="strands-spike-1", + room_id=room_id, + content="Say the magic word", + sender_id="user-1", + sender_type="User", + sender_name="Tester", + message_type="text", + metadata=None, + created_at=datetime.now(timezone.utc), + ) + + +class _ScriptedStrandsModel(Model): + """A streaming Model that replays one scripted decision per invocation. + + Each ``stream()`` call pops one decision and yields the Converse + ``StreamEvent`` sequence Strands' event loop parses (see module docstring): + + * ``("tool", name, args_dict)`` -> a tool-use turn the agent dispatches; + * ``("text", body)`` -> a text turn that ends the run. + """ + + def __init__(self, turns: list[Any]): + self._turns = list(turns) + self._config: dict[str, Any] = {} + + def update_config(self, **model_config: Any) -> None: + self._config.update(model_config) + + def get_config(self) -> Any: + return self._config + + async def structured_output( + self, + output_model: Any, + prompt: Messages, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[dict[str, Any], None]: + raise NotImplementedError("spike model does not do structured output") + yield {} # pragma: no cover - makes this an async generator + + async def stream( + self, + messages: Messages, + tool_specs: list[ToolSpec] | None = None, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[StreamEvent, None]: + decision = self._turns.pop(0) if self._turns else ("text", "done") + yield {"messageStart": {"role": "assistant"}} + if decision[0] == "tool": + _, name, args = decision + yield { + "contentBlockStart": { + "start": {"toolUse": {"toolUseId": f"call-{name}", "name": name}} + } + } + yield { + "contentBlockDelta": {"delta": {"toolUse": {"input": json.dumps(args)}}} + } + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "tool_use"}} + else: + yield {"contentBlockStart": {"start": {}}} + yield {"contentBlockDelta": {"delta": {"text": decision[1]}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + + +async def _run(adapter: StrandsAdapter, tools: FakeAgentTools, room_id: str) -> None: + """Drive the adapter through its real lifecycle: on_started -> on_message.""" + await adapter.on_started("StrandsSpikeBot", "Tier-1 Strands injection spike bot.") + await adapter.on_message( + msg=_make_msg(room_id), + tools=cast("AgentToolsProtocol", tools), + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=room_id, + ) + + +@pytest.mark.asyncio +async def test_scripted_model_routes_to_typed_send_message() -> None: + """A scripted tool-use decision drives the real platform-tool wrapper (L0). + + Observed on messages_sent (typed-method dispatch), NOT tool_calls. + """ + room_id = "strands-spike-room" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter( + model=_ScriptedStrandsModel( + [ + ( + "tool", + "band_send_message", + {"content": _SEND_CONTENT, "mentions": _SEND_MENTIONS}, + ), + ("text", "done"), + ] + ) + ) + await _run(adapter, tools, room_id) + + # Strands dispatches platform tools through typed AgentToolsProtocol + # methods, so observe on messages_sent (the contract's observation_paths). + assert len(tools.messages_sent) == 1, ( + f"expected one send_message dispatch via the real tool wrapper, " + f"got messages_sent={tools.messages_sent}, tool_calls={tools.tool_calls}" + ) + assert tools.messages_sent[0]["content"] == _SEND_CONTENT + assert tools.messages_sent[0]["mentions"] == _SEND_MENTIONS + # And confirm it did NOT route through execute_tool_call (the dual-path proof). + assert tools.tool_calls == [], ( + f"Strands platform tools should bypass execute_tool_call; got {tools.tool_calls}" + ) + # The terminal band action means no error event was reported. + assert not [e for e in tools.events_sent if e["message_type"] == "error"] + + +@pytest.mark.asyncio +async def test_custom_tool_decision_dispatches_to_handler() -> None: + """A scripted custom-tool decision reaches the custom handler with validated args (L1).""" + + calls: list[str] = [] + + class EchoInput(BaseModel): + """Echo the given text back.""" + + text: str + + async def echo_handler(args: EchoInput) -> str: + calls.append(args.text) + return f"echo: {args.text}" + + # Terminal opt-in: the custom tool completes the turn, so no error is reported. + echo_handler.band_terminal = True # type: ignore[attr-defined] + + room_id = "strands-spike-custom" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter( + model=_ScriptedStrandsModel( + [ + ("tool", "echo", {"text": "MANGO"}), + ("text", "done"), + ] + ), + additional_tools=[(EchoInput, echo_handler)], + ) + await _run(adapter, tools, room_id) + + assert calls == ["MANGO"], f"custom handler not dispatched: {calls}" + assert tools.messages_sent == [] # the custom tool is not a platform send + # band_terminal opt-in makes the turn productive: no error event. + assert not [e for e in tools.events_sent if e["message_type"] == "error"] + + +@pytest.mark.asyncio +async def test_l6_execution_events_ordered_paired_and_correlated() -> None: + """Emit.EXECUTION produces tool_call before tool_result, correlated by id (L6).""" + room_id = "strands-spike-l6" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter( + model=_ScriptedStrandsModel( + [ + ( + "tool", + "band_send_message", + {"content": _SEND_CONTENT, "mentions": _SEND_MENTIONS}, + ), + ("text", "done"), + ] + ), + features=AdapterFeatures(emit={Emit.EXECUTION}), + ) + await _run(adapter, tools, room_id) + + execution = [ + (e["message_type"], json.loads(e["content"])) + for e in tools.events_sent + if e["message_type"] in ("tool_call", "tool_result") + ] + assert [kind for kind, _ in execution] == ["tool_call", "tool_result"], ( + f"expected one ordered tool_call/tool_result pair, got {execution}" + ) + call, result = execution[0][1], execution[1][1] + assert call["name"] == "band_send_message" + assert call["args"] == {"content": _SEND_CONTENT, "mentions": _SEND_MENTIONS} + assert result["name"] == "band_send_message" + assert result["tool_call_id"] == call["tool_call_id"], ( + f"tool_result not correlated with its tool_call: {execution}" + ) + + +@pytest.mark.asyncio +async def test_negative_control_text_only_sends_no_message() -> None: + """A text-only scripted decision dispatches nothing and reports the dropped reply.""" + room_id = "strands-spike-negative" + tools = FakeAgentTools(room_id=room_id) + adapter = StrandsAdapter( + model=_ScriptedStrandsModel([("text", "just a reply, no tools")]) + ) + await _run(adapter, tools, room_id) + + assert tools.messages_sent == [], ( + f"expected no send for a text-only decision, got: {tools.messages_sent}" + ) + assert tools.tool_calls == [] + # The plain-text answer was silently dropped — the adapter must surface it. + errors = [e for e in tools.events_sent if e["message_type"] == "error"] + assert len(errors) == 1, f"expected one error event, got: {tools.events_sent}" + assert "band_send_message" in errors[0]["content"] diff --git a/uv.lock b/uv.lock index 17fd84de1..a4ddc0edc 100644 --- a/uv.lock +++ b/uv.lock @@ -462,6 +462,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, ] +[[package]] +name = "aws-bedrock-token-generator" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/39/cf1c2e12bc5a84af0f96a546481213f81fa6e7927d2bbabd81758c6558ca/aws_bedrock_token_generator-1.1.0.tar.gz", hash = "sha256:95ccb07f63a91ac486561f6df05cc4e04784c8ff5086dc687ed9c5fd3ab1b5ba", size = 19123, upload-time = "2025-07-29T19:53:19.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/fd/745ece98870c3824d294bcdce5dc5e15381188a41bc80832c246b205e40e/aws_bedrock_token_generator-1.1.0-py3-none-any.whl", hash = "sha256:bd12854f7c7e52dde5d980d369379f12d0cc5f0855099d87f38688b0f9de5cd4", size = 10291, upload-time = "2025-07-29T19:53:18.704Z" }, +] + [[package]] name = "backoff" version = "2.2.1" @@ -607,6 +619,7 @@ dev = [ { name = "ruff" }, { name = "slack-sdk" }, { name = "starlette" }, + { name = "strands-agents", extra = ["openai"], 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 = "thenvoi-testing-python" }, { name = "uvicorn" }, { name = "werkzeug" }, @@ -673,6 +686,9 @@ slack = [ { name = "starlette" }, { name = "uvicorn" }, ] +strands = [ + { name = "strands-agents", extra = ["openai"] }, +] [package.metadata] requires-dist = [ @@ -786,6 +802,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 = "strands-agents", extras = ["openai"], marker = "extra == 'dev'", specifier = ">=1.40,<2" }, + { name = "strands-agents", extras = ["openai"], marker = "extra == 'strands'", specifier = ">=1.40,<2" }, { 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" }, @@ -798,7 +816,7 @@ requires-dist = [ { name = "werkzeug", marker = "extra == 'dev'", specifier = ">=3.1.6" }, { name = "werkzeug", marker = "extra == 'parlant'", specifier = ">=3.1.6" }, ] -provides-extras = ["logging", "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 = ["logging", "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", "strands", "dev", "dev-crewai"] [[package]] name = "bcrypt" @@ -5690,21 +5708,198 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/77/154004c99fb9f291f74aa0822a2f5bbf565a72d8126b3a1b63ed8e5f83c7/opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b", size = 19579, upload-time = "2025-10-16T08:35:36.269Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.34.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "opentelemetry-semantic-conventions", version = "0.55b1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "wrapt", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/69/d8995f229ddf4d98b9c85dd126aeca03dd1742f6dc5d3bc0d2f6dae1535c/opentelemetry_instrumentation-0.55b1.tar.gz", hash = "sha256:2dc50aa207b9bfa16f70a1a0571e011e737a9917408934675b89ef4d5718c87b", size = 28552, upload-time = "2025-06-10T08:58:15.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7d/8ddfda1506c2fcca137924d5688ccabffa1aed9ec0955b7d0772de02cec3/opentelemetry_instrumentation-0.55b1-py3-none-any.whl", hash = "sha256:cbb1496b42bc394e01bc63701b10e69094e8564e281de063e4328d122cc7a97e", size = 31108, upload-time = "2025-06-10T08:57:14.355Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.59b0" 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'", +] dependencies = [ - { name = "opentelemetry-api", version = "1.38.0", source = { registry = "https://pypi.org/simple" } }, - { name = "opentelemetry-semantic-conventions", version = "0.59b0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "wrapt" }, + { name = "opentelemetry-api", version = "1.38.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 = "opentelemetry-semantic-conventions", version = "0.59b0", 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 = "packaging", 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 = "wrapt", 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/04/ed/9c65cd209407fd807fa05be03ee30f159bdac8d59e7ea16a8fe5a1601222/opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc", size = 31544, upload-time = "2025-10-16T08:39:31.959Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/f5/7a40ff3f62bfe715dad2f633d7f1174ba1a7dd74254c15b2558b3401262a/opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee", size = 33020, upload-time = "2025-10-16T08:38:31.463Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.34.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "opentelemetry-instrumentation", version = "0.55b1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "wrapt", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/a2/470bbc9b7060372d6b183d999080c12a63fb459fc77b9be0ca15694ecabe/opentelemetry_instrumentation_threading-0.55b1.tar.gz", hash = "sha256:4ed68502e7ed017bfc10b1f9e508cc5ccaea0e46ac1010f7f2541ab9c6eacd92", size = 8767, upload-time = "2025-06-10T08:58:46.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/c9/183ad41a7ba0374030b3eab335ec6f3eff6acca057aba2b393183e18639e/opentelemetry_instrumentation_threading-0.55b1-py3-none-any.whl", hash = "sha256:f865542b32b219c8fd01deb03b8c3c9ba2eb3f0501ae303338403fd2242962c7", size = 9313, upload-time = "2025-06-10T08:58:02.884Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.59b0" +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'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.38.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 = "opentelemetry-instrumentation", version = "0.59b0", 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 = "wrapt", 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/82/7a/84e97d8992808197006e607ae410c2219bdbbc23d1289ba0c244d3220741/opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3", size = 8770, upload-time = "2025-10-16T08:40:03.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/32d29076aaa1c91983cdd3ca8c6bb4d344830cd7d87a7c0fdc2d98c58509/opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca", size = 9313, upload-time = "2025-10-16T08:39:15.876Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.34.1" @@ -6229,7 +6424,7 @@ dependencies = [ { name = "openapi3-parser" }, { name = "opentelemetry-api", version = "1.38.0", source = { registry = "https://pypi.org/simple" } }, { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation", version = "0.59b0", source = { registry = "https://pypi.org/simple" } }, { name = "opentelemetry-sdk", version = "1.38.0", source = { registry = "https://pypi.org/simple" } }, { name = "parlant-client" }, { name = "python-dateutil" }, @@ -8424,6 +8619,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "strands-agents" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "jsonschema" }, + { name = "mcp", version = "1.26.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "mcp", version = "1.27.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 = "opentelemetry-api", version = "1.34.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "opentelemetry-api", version = "1.38.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 = "opentelemetry-instrumentation-threading", version = "0.55b1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "opentelemetry-instrumentation-threading", version = "0.59b0", 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 = "opentelemetry-sdk", version = "1.34.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai'" }, + { name = "opentelemetry-sdk", version = "1.38.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 = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/05/5eb8431ff340739b1c21b0da7d19ec9604b9c4953a1c4638df915321573c/strands_agents-1.47.0.tar.gz", hash = "sha256:97770cb6beb6e5fd1a58849f41201eb7edb43fd67ad3de6544ada2f342c2bcf0", size = 1157139, upload-time = "2026-07-10T14:45:05.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/cb/ceae892c5823bea9254160efc02a4553f2ced9d183676110c03c3a9ff0f3/strands_agents-1.47.0-py3-none-any.whl", hash = "sha256:1f6ce17404ff02079244ad7a4a180a2a7150546275e4b91187d71472ba8fe3f2", size = 600611, upload-time = "2026-07-10T14:45:03.942Z" }, +] + +[package.optional-dependencies] +openai = [ + { name = "aws-bedrock-token-generator" }, + { name = "openai" }, +] + [[package]] name = "structlog" version = "25.5.0" From d5a8ae270e3bde0fdcd4d99a4e4630a724c5e2b4 Mon Sep 17 00:00:00 2001 From: Nir Singher Date: Mon, 13 Jul 2026 17:47:58 +0300 Subject: [PATCH 2/2] chore: retrigger CI